Основы разработки прикладных виртуальных драйверов
Часть 4. Программное управление нестандартной аппаратурой
Как известно, подключение к компьютеру измерительной или управляющей аппаратуры выполняется одним из двух способов: с отображением на пространство портов (пространство ввода-вывода) и с отображением на общее с памятью адресное пространство. В первом случае за регистрами подключаемой аппаратуры закрепляется требуемое количество свободных адресов из пространства портов (например, 300h...308h), а программирование аппаратуры осуществляется исключительно командами in или out. Во втором случае регистрам аппаратуры выделяется свободный участок адресного пространства первого мегабайта (за пределами первых 640 Кбайт, занятых оперативной памятью), например область D0000h...D0320h, а при программировании аппаратуры можно использовать любые команды процессора. Первый способ применяется при подключении относительно простой аппаратуры, для управления которой достаточно лишь небольшого количества регистров. Применение второго способа целесообразно в случаях, когда аппаратура содержит большое количество регистров или внутреннюю память, которую желательно сделать непосредственно адресуемой со стороны компьютера. Примером устройства такого рода является система КАМАК, широко используемая для автоматизации экспериментальных физических установок.
Программирование аппаратуры КАМАК или ей подобной (в плане подключения к компьютеру) в системе DOS осуществляется элементарно. В один из сегментных регистров данных, обычно в регистр ES, засылается сегментный адрес участка адресного пространства, на который настроен дешифратор адресов, включаемый в состав интерфейсной части аппаратуры:
mov AX,0D000h mov ES,AX
После этого обращение к требуемому регистру осуществляется любой подходящей командой процессора (mov, test, or, and и т.д.) с указанием смещения этого регистра относительно базового адреса выделенного участка памяти:
mov BX,32h ;Задание смещения к конкретному регистру mov AX,ES:[BX] ;Чтение из регистра 32h аппаратуры mov ES:[BX+2],CX;Запись в регистр 34h аппаратуры
Данные операции нельзя выполнить в обычном 16-разрядном приложении Windows, которое работает в виртуальном адресном пространстве и не имеет доступа к физической памяти. Однако обращение к любому участку физической памяти легко осуществить с помощью виртуального драйвера, который может вызовом соответствующих функций VMM отобразить заданный диапазон физических адресов на линейное адресное пространство и, более того, предоставить приложению виртуальный адрес в формате «селектор:смещение» для непосредственной работы с физической памятью.
Для отладки и исследования такого рода драйвера лучше воспользоваться ПЗУ BIOS, которое, как известно, расположено по фиксированным физическим адресам F000h:0000h...FFFFh. Содержимое любого байта ПЗУ BIOS легко прочитать с помощью какого-либо отладчика, например программы DEBUG, входящей в состав DOS, и проконтролировать таким образом результат применения виртуального драйвера. Рассматриваемый ниже пример представляет собой программный комплекс (виртуальный драйвер и вызывающее его приложение Windows), в котором выполняется чтение нескольких байтов ПЗУ BIOS. Рассмотрим сначала текст приложения Windows.
Текст приложения Windows, читающего из ПЗУ BIOS дату его выпуска.
#define STRICT #include <windows.h> #include <windowsx.h> void GetAPIEntry(); FARPROC VxDEntry; char* ptr; //Главная функция WinMain int WINAPI WinMain(HINSTANCE,HINSTANCE,LPSTR,int){ char txt[80]; GetAPIEntry(); _DI=OFFSETOF(&ptr); _SI=0xF000; VxDEntry(); for(int i=0;i<=7;i++) txt[i]=ptr[i+0xfff5]; txt[i]=0; MessageBox(NULL,txt,"Info",MB_ICONINFORMATION); return 0; } //Функция GetAPIEntry() получения точки входа в драйвер void GetAPIEntry(){ asm{ mov AX,0x1684 mov BX,0x8000 int 0x2F mov word ptr VxDEntry,DI mov word ptr VxDEntry+2,ES } }
По своей структуре и функционированию эта программа не отличается от примера из части 2 настоящего цикла статей. В нее вошли главная функция WinMain() и функция GetAPIEntry() получения точки входа в драйвер. В области глобальных переменных объявлена дополнительная переменная ptr типа «указатель на символы». В эту двухсловную переменную драйвер вернет созданный им виртуальный адрес ПЗУ BIOS, после чего приложение Windows, задавая то или иное смещение, сможет обращаться к любым байтам всей 64-килобайтной области адресов BIOS.
Получив с помощью вызова GetAPIEntry() адрес точки входа в драйвер, следует подготовить параметры для передачи драйверу. В регистрах DS:DI будет передан адрес переменной ptr; в регистре SI — сегментный адрес той области памяти, которую мы хотим отобразить на линейные адреса. После вызова VxDEntry() в переменной ptr оказывается виртуальный адрес ПЗУ BIOS. По правилам языка Си в этом случае ptr[0] обозначает самый первый байт этой области, ptr[1] — следующий байт, а ptr[0xFFF5] — байт со смещением 0xFFF5 от начала ПЗУ. В цикле из 8 шагов 8 байт ПЗУ начиная с адреса 0xFFF5 заносятся в символьную строку txt, в байт txt[9] записывается 0, и функцией MessageBox() строка txt выводится в окно сообщения. Завершающий ноль служит для того, чтобы функция MessageBox() знала, какую часть строки txt следует вывести на экран. В языке Си символьные строки всегда заканчиваются двоичным (не символьным) нулем, который для функций, обслуживающих строки, является признаком конца строки.
На рис. 1 приведен вывод рассмотренной программы — содержимое ячеек ПЗУ BIOS со смещениями FFF5h...FFFCh, где хранится дата выпуска данного варианта BIOS.
Теперь перейдем к рассмотрению виртуального драйвера, выполняющего отображение физических адресов на виртуальные. Фактически нам достаточно рассмотреть его API-процедуру.
Виртуальный драйвер, отображающий ПЗУ BIOS на виртуальные адреса приложения.
.386p .XLIST include vmm.inc .LIST Declare_Virtual_Device VMyD,1,0,VMyD_Control,8000h, \ Undefined_Init_Order,API_Handler,API_Handler ;======================= VxD_REAL_INIT_SEG BeginProc VMyD_Real_Init ;Текст процедуры инициализации реального режима (см. часть 2 этого цикла) EndProc VMyD_Real_Init VxD_REAL_INIT_ENDS ;====================== VxD_CODE_SEG BeginProc VMyD_Control clc ret EndProc VMyD_Control BeginProc API_Handler ;Отобразим BIOS на линейные адреса movzx ESI,[EBP.Client_SI] shl ESI,4 push 0 push 10000h push ESI VMMCall _MapPhysToLinear add ESP,3*4 ;Получим селектор:смещение для начала BIOS mov ECX,10000h VMMCall Map_Lin_To_VM_Addr;CX:EDX=sel:offs shl ECX,16 ;Селектор в старшей части ECX or EDX,ECX ;EDX=sel:offs для обращения к BIOS Client_Ptr_Flat EDI,DS,DI;Получили адрес указателя из приложения mov [EDI],EDX ret EndProc API_Handler VxD_CODE_ENDS end VMyD_Real_Init
В этом драйвере не оказалось полей данных (которые присутствовали в примере части 3), поэтому из него удален сегмент данных.
Для отображения физических адресов на линейные служит функция VMM _MapPhysToLinear. В справочнике DDK приведен следующий формат вызова этой функции:
VMMCall _MapPhysToLinear, <PhysAddr,nBytes,flags>,
где PhysAddr — базовый физический адрес отображаемого участка памяти, nBytes — размер отображаемого участка в байтах, а слово флагов flags должно иметь нулевое значение. Такая форма вызова функции VMM предполагает указание параметров в виде конкретных чисел. Однако для нас желательно иметь универсальный драйвер, которому можно было бы передавать базовый физический адрес в виде параметра. Дело в том, что аппаратура, адреса которой находятся в памяти, всегда имеет средства настройки базового адреса, что позволяет устранять возможное наложение адресов, если к компьютеру подключено несколько таких установок. Поэтому драйвер, обслуживающий эту аппаратуру, также должен иметь средства настройки базового отображаемого адреса.
Обозначение VMMCall _MapPhysToLinear, несмотря на внешний вид, напоминающий вызов функции VMM, по существу представляет собой макрос. Если в программе этот макрос используется с перечислением фактических параметров, ассемблер расширяет его, например следующим образом:
push flags push nBytes push PhysAddr int 20h dd @@MapPhysToLinear ;0001006Ch add ESP,12
Перечисленные в макровызове фактические параметры проталкиваются в стек, после чего вызывается прерывание 20h с указанием в следующем слове программы условного кода, соответствующего функции VMM MapPhysToLinear. Нетрудно сообразить, что системный обработчик прерывания 20h должен считать из программы код, следующий за командой прерывания, и передать управление на соответствующую процедуру VMM, которая в данном случае выделяет свободный диапазон линейных адресов и заполняет новые элементы таблицы трансляции, если указанные физические адреса еще не отображены на линейное адресное пространство. В процессе выполнения этой процедуры используются параметры, находящиеся в стеке, однако указатель стека при этом не смещается. Восстановление стека (путем прибавления к ESP общей длины переданных параметров) происходит уже в драйвере, после возврата из VMM последней командой макрорасширения.
Если макрос VMMCall _MapPhysToLinear вызывается без параметров, то в его макрорасширении отсутствуют как команды проталкивания в стек, так и команда восстановления стека. Поэтому обе эти операции следует включить в программу драйвера явным образом:
push 0 ;Имитация макроса с параметрами - push 10000h ;проталкивание параметров push ESI ;в стек VMMCall _MapPhysToLinear add ESP,3*4 ;Восстановление стека
Поскольку в число параметров функции MapPhysToLinear входит базовый физический адрес, а для нас естественнее передать в драйвер базовый сегментный адрес, в начале API-процедуры драйвера этот параметр переносится из структуры клиента в регистр ESI и сдвигается влево на 4 бит для образования физического адреса. Диапазон отображения (10000h = 64 Кбайт) задается в драйвере, хотя при необходимости его можно передать и в качестве параметра через один из регистров.
Макрос VMMCall _MapPhysToLinear возвращает линейный адрес в регистре EAX (в случае ошибки возвращается код FFFFFFFFh). Второй этап преобразования — образование виртуального адреса, соответствующего полученному линейному, — осуществляется с помощью вызова макроса VMMCall Map_Lin_To_VM_Addr (обратите внимание на отсутствие символа подчеркивания перед именем макроса). Макрос VMMCall Map_Lin_To_VM_Addr требует в качестве параметров наличия в регистре EAX преобразуемого линейного адреса, а в ECX — предела создаваемого сегмента. В нашем случае линейный адрес уже находится в EAX, остается только заслать границу сегмента — число FFFFh — в регистр ECX.
Макрос VMMCall Map_Lin_To_VM_Addr возвращает в регистре CX селектор образованного сегмента, а в регистре EDX — смещение к заданному линейному адресу, которое, впрочем, в приложениях защищенного режима всегда равно 0. Полученный селектор смещается в старшую половину ECX и объединяется со смещением с образованием в регистре EDX обычного для 16-разрядного приложения формата дальнего адреса (селектор в старшем слове адреса, смещение в младшем). Следует заметить, что для приложения Windows эта операция является лишней, так как смещение всегда равно 0; мы включили ее в драйвер для общности.
В последних предложениях API-процедуры драйвера с помощью макроса Client_Ptr_Flat формируется (в регистре EDI) линейный адрес поля данных ptr в приложении Windows, после чего полученный ранее виртуальный адрес физической памяти пересылается непосредственно в поле ptr. Приложение Windows, получив этот адрес, может обращаться ко всем отображенным физическим адресам, как это описывалось выше.
Теперь рассмотрим особенности программного управления аппаратурой, подключаемой к компьютеру через выделенные для нее порты.
В большинстве случаев подключение к компьютеру нестандартного внешнего устройства (например, автоматизируемой установки) осуществляется путем разработки для этого устройства интерфейсной платы, вставляемой в свободный разъем расширения («слот»). Со стороны системной магистрали интерфейсная плата должна поддерживать стандартные протоколы обмена данными (операции ввода и вывода, а также, возможно, протокол прерываний), а со стороны, обращенной к устройству, формировать необходимые для управления устройством сигналы и данные. Такая интерфейсная плата обычно обладает ограниченным набором регистров управления и данных (часто всего 2-3), которым с помощью дешифратора адреса, установленного на плате, назначаются определенные адреса из области ввода-вывода (0000h…FFFFh). Чаще всего эти адреса (порты) лежат в диапазоне 000h-3FFh. Разумеется, адреса, назначенные устройству, не должны совпадать с адресами штатных устройств компьютера. Программирование устройства выполняется в этом случае с помощью команд ввода-вывода (in, out и др.).
Реакция процессора Intel на команды ввода-вывода обращения к тому или иному порту зависит прежде всего от соотношения уровня привилегий ввода-вывода и текущего уровня привилегий (CPL) выполняемого сегмента приложения, в котором встретилась команда ввода-вывода. Уровень привилегий ввода-вывода определяется значением поля IOPL в регистре флагов процессора и при выполнении Windows-приложений всегда равен 0. Текущий уровень привилегий CPL задается значением двух младших битов в селекторе сегментов и для виртуальных драйверов (в частности, разработанных пользователем и включенных в систему) равен 0, а для обычных Windows-приложений – 3.
Если CPL=IOPL (случай виртуального драйвера), команды ввода-вывода выполняются процессором обычным образом — путем непосредственного обращения к порту. Если же CPL>IOPL (случай обычного приложения), то процессор при команде ввода-вывода обращается к карте разрешения ввода-вывода в сегменте состояния задачи TSS. Эта карта содержит по одному биту на каждый адрес из пространства ввода-вывода и занимает, таким образом, объем 64 Кбит = 8 Кбайт. Каждый бит карты может быть по отдельности сброшен (обращение к данному порту разрешено) или установлен (обращение к порту запрещено). В случае CPL>IOPL процессор, выполняя команду ввода-вывода для некоторого порта, анализирует состояние соответствующего ему бита в карте разрешения ввода-вывода и при нулевом значении этого бита выполняет непосредственное обращение к порту, а при единичном – формирует исключение общей защиты с номером 13 = 0Dh. Дальнейшая судьба затребованной операции ввода-вывода определяется алгоритмом работы обработчика исключения общей защиты, являющегося одним из элементов менеджера виртуальной машины VMM, а также наличием или отсутствием виртуального драйвера для данного порта.
Карта разрешения ввода-вывода, формируемая Windows при загрузке системы, содержит единицы почти во всех битах. Открыты лишь порты 70h и 71h (КМОП-микросхема), 378h-37Fh (LPT1), 3F8h-3FEh (COM1) и некоторые другие. Таким образом, обращение практически к любому порту влечет исключение общей защиты, передающее управление системному обработчику этого исключения. Этот обработчик представляет собой 32-битовую программу уровня 0, расположенную в четвертом гигабайте линейного адресного пространства по адресу, например, 0028h:C00012B0h.
Дескриптор 0Dh таблицы дескрипторов прерываний IDT (как и многие другие дескрипторы этой таблицы) имеет атрибут 8Eh, что означает: присутствие, DPL=0, шлюз прерывания. Поскольку приложение выполняется на уровне 3, исключение предполагает переход на внутренний уровень 0, что усложняет процедуру прерывания. Процессор, реализуя эту процедуру, прежде всего переходит на стек уровня 0, кадр которого (значения SS:ESP) берется из TSS (поля со смещениями 4 и 8). Затем в стеке уровня 0 сохраняются значения SS:ESP приложения в точке прерывания, флаги процессора, значения CS:EIP (в данном случае – адрес еще не выполненной команды ввода-вывода) и, наконец, код ошибки. После этого в CS:EIP загружается адрес обработчика исключения 0Dh, находящийся в дескрипторе IDT, и начинает выполняться программа этого обработчика, который прежде всего с помощью команды pushad формирует на стеке уровня 0 структуру регистров клиента (рис. 2), после чего переходит к анализу причины исключения.
Исключение общей защиты может произойти в силу самых разных причин: выход за пределы сегмента, засылка в сегментный регистр несуществующего селектора, выполнение команды int с номером, отсутствующим в таблице дескрипторов прерываний, и т.д. Обработчик исключения общей защиты определяет причину исключения, анализируя код команды, вызвавшей это исключение. Поскольку в стеке уровня 0, на котором работает обработчик, хранится адрес этой команды (значения CS:EIP), обработчик имеет возможность «залезть» по этому адресу в приложение, прочитать код команды и определить ход своих дальнейших действий.
Обработка исключения от команд in или out выполняется схожим образом, хотя и с некоторыми отличиями. Прежде всего над номером порта выполняется ряд последовательных операций побитового сдвига и сложения по модулю 2, приводящих к образованию псевдослучайного числа, не превышающего 2FEh. Это число в дальнейшем используется в качестве индекса в таблицах, хранящих информацию о портах. Такой метод индексирования (называемый хешированием) позволяет существенно сократить объем этих таблиц, но при этом создает проблему неоднозначности: каждому псевдослучайному числу может соответствовать несколько портов. Например, в число 2 преобразуются номера портов 80h, 100h, 281h и 301h, а в число C0h — номера портов 20h, 1A0h, 221h и 3A1h.
Используя полученное псевдослучайное число в качестве индекса, VMM обращается к таблице двухбайтовых величин, в которой хранится информация о наличии в системе виртуального драйвера для каждого порта. Если для данного порта виртуальный драйвер установлен, в соответствующей порту ячейке таблицы записан номер этого порта. Если драйвера нет, в ячейке записан 0. Заполнение таблицы номерами обслуживаемых Windows портов выполняется на этапе установки виртуальных драйверов в процессе загрузки системы.
В составе VMM имеется еще одна таблица (4-байтовых величин), адресация которой осуществляется с помощью тех же псевдослучайных чисел, предварительно умноженных на 2. В этой таблице хранятся адреса процедур виртуальных драйверов, предназначенных для обслуживания конкретных портов. Для свободных портов, не используемых Windows, в соответствующих ячейках этой таблицы хранится адрес процедуры обслуживания портов по умолчанию. Именно эта процедура будет вызвана при первом обращении к порту нестандартного внешнего устройства, если для него предварительно не написан виртуальный драйвер.
Обнаружив в ячейке первой таблицы номер искомого порта, VMM извлекает из второй таблицы адрес его драйвера и передает ему управление. Процедура виртуального драйвера, соответствующая данному порту, может, например, просто выполнить затребованную команду (in или out) и вернуть управление в приложение на команду, следующую за обработанной командой ввода-вывода. Поскольку VMM работает на нулевом уровне привилегий, он может обращаться ко всем портам, не вызывая нарушения общей защиты, даже если для данного порта установлен бит карты разрешения ввода-вывода в TSS.
Если в адресуемой ячейке первой таблицы не обнаружен номер искомого порта, это может означать, что либо для данного порта нет виртуального драйвера (но тогда в соответствующей ячейке второй таблицы должен храниться адрес процедуры обслуживания портов по умолчанию, которой и следует передать управление), либо в этой ячейке записана информация о каком-либо другом порте, номер которого преобразуется в то же псевдослучайное число. В этом случае выполняется анализ содержимого второй таблицы. Если там находится адрес процедуры по умолчанию (а это значит, что для данного порта виртуальный драйвер отсутствует), управление передается этой процедуре. Если же в ячейке второй таблицы иной адрес (то есть, видимо, адрес виртуального драйвера другого порта, отображаемого на ту же ячейку таблицы), выполняются декремент псевдослучайного числа и анализ содержимого предыдущих ячеек обеих таблиц. Эта процедура смещения по ячейкам таблиц влево повторяется до тех пор, пока в первой таблице не будет найден номер искомого порта, или во второй таблице не будет обнаружен адрес процедуры по умолчанию.
В результате нарушение общей защиты, вызванное командами in или out, приводит к передаче управления либо на виртуальный драйвер соответствующего порта, либо на процедуру обслуживания портов по умолчанию.
В процедуре по умолчанию выполняется целый ряд действий, из которых самым важным для нас является сброс бита карты разрешения ввода-вывода в TSS, соответствующего обслуживаемому порту. Эта операция выполняется командой
btr [tss+68h],EDX
Перед выполнением этой команды в регистр EDX помещается номер адресуемого порта. Используемое в команде смещение представляет собой линейный адрес карты защиты ввода-вывода в TSS. Как известно, эта карта начинается в TSS со смещения 68h. Любопытно, что первым операндом этой команды в такой редакции является не регистр или ячейка памяти, а огромное поле памяти объемом 8 Кбайт, в котором с помощью второго операнда отыскивается и сбрасывается требуемый бит.
Сброшенное состояние бита сохраняется до перезагрузки Windows. Таким образом, в дальнейшем команды ввода-вывода с обращением к этому порту будут выполняться процессором непосредственно, без нарушения общей защиты.
Выполнив коррекцию карты разрешения ввода-вывода в TSS, VMM устанавливает в структуре клиента значение EIP на адрес обрабатываемой команды ввода-вывода (в процессе обработки EIP был смещен на адрес следующей команды) и, восстановив из структуры клиента значения всех регистров процессора, с помощью команды дальнего возврата из прерывания iretd передает управление в приложение на ту же, еще не выполненную команду ввода-вывода. Поскольку данный порт теперь открыт, команда выполняется без помех.
Таким образом, программное обращение к портам, не используемым Windows, можно выполнять обычным образом, с помощью команд in и out, без каких-либо дополнительных программных средств. При этом, однако, следует иметь в виду, что первое обращение в любому порту будет проходить через обработчик нарушения общей защиты, что приведет к издержкам в несколько сотен команд. Последующие команды ввода-вывода будут выполняться непосредственно, без каких-либо задержек. В случае необходимости можно включить в процедуру инициализации устройства фиктивные команды обращения ко всем его портам, чтобы открыть к ним доступ в карте TSS. Можно также написать и включить в систему виртуальный драйвер, который на этапе инициализации системы (или установки) снимет маску с требуемых портов в TSS. Наконец, можно включить в систему «полновесный» виртуальный драйвер, который возьмет на себя выполнение команд ввода-вывода для требуемых портов. В этом случае команды in и out будут по-прежнему вызывать нарушение общей защиты, однако оно будет обрабатываться не процедурой по умолчанию (снимающей маску в TSS), а прикладным виртуальным драйвером. Хотя такой метод наиболее соответствует идеологии Windows, но для него характерны максимальные временные издержки и отсутствие видимых преимуществ по сравнению с прямым программированием через порты.
Обращение к порту в 16-разрядном приложении Windows можно выполнить с помощью функций С inportb, outportb, прототипы которых описаны в заголовочном файле dos.h:
#include <dos.h> outportb (0x300,0x71); unsigned char data=inportb(0x301);
В этом фрагменте функцией С outportb() в порт 300h выводится число 71h (предположительно команда управления устройством). В следующем предложении функцией inportb() из порта 301h вводится данное. Принимающая переменная должна быть объявлена в этом случае как символьная без знака или типа BYTE. Предусмотрены и другие варианты функций ввода-вывода через порты, например функция inport(port), которая сразу вводит целое слово: младший байт из указанного порта port и старший байт из порта port + 1.
При программировании портов в 32-разрядных приложениях Windows возникает некоторая сложность из-за отсутствия функций вида inport(), outport(). Однако обращение к командам ввода-вывода нетрудно оформить в виде ассемблерных вставок. В приведенном ниже отрывочном фрагменте выполняется ожидание установки бита 7 в порте 30Ah, который сигнализирует о готовности данных в выходных портах установки 308h (младший байт данных) и 309h (старший байт). После получения сигнала готовности выполняются чтение данных из портов 308h и 309h и вывод этих данных в десятичной форме в окно сообщения.
Фрагмент 32-разрядного приложения Windows, выполняющей прямое обращение к портам.
unsigned short int result; //Ячейка для результата char txt[80]; //Символьная строка-буфер ... waitx: //Метка //Будем ожидать установки бита 7 в порте 30Ah asm{ //Начало ассемблерной вставки mov DX,0x30A //Номер порта состояния in AL,DX //Ввод из порта test AL,0x80 //Анализ бита 7 je waitx //Если 0, ждать дальше } //Конец ассемблерной вставки //Прочитаем данные из установки asm{ //Начало новой вставки mov DX,0x309 //Порт данных (старший байт) in AL,DX //Ввод старшего байта mov AH,AL //В AH его mov DX,0x308 //Порт данных (младший байт) in AL,DX //Ввод младшего байта } //Конец вставки result=_AX; //Поместим результат в переменную wsprintf(txt,"Накоплено %d событий",result);//Преобразуем данное в символы MessageBox(NULL,txt,"Данные",MB_ICONSTOP);//Окно сообщения }
Приведенный фрагмент взят из реальной программы управления измерительной установкой; вывод этой программы после завершения измерений показан на рис. 3.
КомпьютерПресс 6'2001