Прочитал я недавно где-то в интернете, что драйвер параллельного порта в Windows 2000 и Windows XP непосредственно поддерживает работу с устройствами в режимах EPP и ECP, и решил проверить, в чем это выражается и как это использовать.
Автор:
Разместил: Amro   Дата: 2006-05-29 14:09
Комментарии: (0)   Рейтинг:
Пока комментариев нет

Работа с параллельным портом под Windows

Кузин Ю.Р., «Королевство Delphi»

Прочитал я недавно где-то в интернете, что драйвер параллельного порта в Windows 2000 и Windows XP непосредственно поддерживает работу с устройствами в режимах EPP и ECP, и решил проверить, в чем это выражается и как это использовать. Меня больше интересовал режим EPP, который более практичен, так как представляет собой "вынос" шины ISA за пределы компьютера. Попытки найти что-то путное в интернете привели к статье Тарасенко, где неплохо изложены общие принципы работы с драйвером параллельного порта. Но этого было недостаточно, поэтому пришлось лезть на MSDN и посмотреть, что по этому поводу говорит Майкрософт. Далекий от совершенства online-справочник сориентирован в основном на разработчиков драйверов, поскольку предполагает специальные знания на каждом шагу. Поэтому одновременно с библиотечным разделом Operating a Parallel Device Attached to a Parallel Port мне пришлось держать открытыми некоторые файлы из DDK. Возможно потому, что я сам скорее железячник, чем программист, я избегаю написания собственных драйверов. Ведь для того, чтобы мое устройство заработало на чужом компьютере, туда придется поставить собственный драйвер кустарного производства, а это, во-первых, неудобно, во-вторых, может привести к "непредсказуемому поведению системы": начиная от дырок для вирусов (что всегда очень трудно просчитать) и заканчивая тривиальным крахом системы. Как мне кажется, большая часть пользователей склоняется к применению того, что нам досталось от Microsoft в том убогом виде как это есть. Именно для этих людей я и решил написать статью, чтобы помочь им избежать трудностей, с которыми я столкнулся.

Начну с того, что объясню общие правила работы клиента с каким-либо драйвером Windows. Вообще говоря, под клиентом понимается или другой драйвер, работающий в режиме ядра, или приложение, работающее в пользовательском режиме. В MSDN, к сожалению, не часто проводят эту грань различия в том или ином документе, а разница есть: не все, что может использовать клиент-драйвер, может применить клиент-приложение. Проходит время пока следуя тексту, наконец поймешь, что вот именно ЭТО пригодно только для драйвера. Windows следует идеологии многоуровневых драйверов. В качестве примера можно привести драйвер от производителя принтера, использующий для своей работы драйвер параллельного порта Microsoft, тот же самый которым мы хотели бы воспользоваться непосредственно из приложения. Забегая вперед скажу, что поскольку мое тестовое устройство все-таки не захотело сразу работать из под Windows, несмотря на нормальную работу в EPP режиме из под DOS, мне пришлось, вооружившись цифровым осциллографом с памятью и логическим анализатором, более внимательно изучить, что же все-таки предлагает Майкрософт за рамками собственной документации. Результаты и побудили меня к написанию этой статьи.

Работа с драйвером устройства (bus driver) из приложения сводится к четырем шагам:

  1. открыть устройство;
  2. настроить нужный режим;
  3. читать из и писать в устройство;
  4. закрыть устройство.

Все эти операции проделываются с помощью механизма запросов ввода-вывода драйвера (IRPs). Пользователю доступны функции, формирующие такие запросы. В Delphi для их использования нужно подключить модуль Windows.pas:

uses Windows;

1 ШАГ

Функция CreateFile формирует запрос, открывающий устройство. Например, такой вызов открывает порт LPT1 для операций асинхронного чтения и записи:

hLPT := CreateFile('LPT1', GENERIC_READ or GENERIC_WRITE, 0, nil,
        OPEN_EXISTING, FILE_FLAG_OVERLAPPED, 0);

Если все запросы должны выполняться синхронно, то вызов будет выглядеть так:

hLPT := CreateFile('LPT1', GENERIC_READ or GENERIC_WRITE, 0, nil,
        OPEN_EXISTING, 0, 0);

Здесь hLPT - дескриптор устройства, который затем используется при обращении к его драйверу:

hLPT: THandle;

2 ШАГ

Управление работой устройства, определение его состояния, поддержка режимов и т.д. осуществляется с помощью запросов IRP_MJ_DEVICE_CONTROL и IRP_MJ_INTERNAL_DEVICE_CONTROL. В режиме пользователя поддерживается только запрос первого типа - он формируется с помощью функции DeviceIoControl. В качестве одного из параметров этой функции фигурирует код операции (IOCTL), который определяет действие, выполняемое драйвером. Для некоторых устройств (например, COM-порта) в API Win32 определены функции управления, которые служат оболочкой для DeviceIoControl при определенном значении IOCTL. Это делает программу более наглядной и читаемой. Для параллельного порта я такой возможности не обнаружил, хотя это не вызывает трудностей, поскольку никто не мешает описать такие функции самому.

Тело такой функции будет сводится к вызову DeviceIoControl с определенным набором параметров. Гораздо более неприятный момент заключается в том, что для LPTx невозможно определить коммуникационное событие, чтобы затем обработать его в отдельном потоке как это можно сделать, например, для последовательного порта с помощью вызова SetCommEvents. Поэтому о готовности Вашего устройства к каким-либо операциям, оно может сообщить, только выставив флаг в некотором своем регистре, который Вы будете регулярно опрашивать (например, по таймеру), что конечно не очень удобно и эффективно. Вообще говоря, в режиме EPP параллельного порта определено внешнее прерывание по положительному переходу на линии 10 (nACK), и драйвер содержит его обработчик. Доступ к обработчику можно получить с помощью IRP_MJ_INTERNAL_DEVICE_CONTROL, но только в режиме ядра. Еще раз хочу подчеркнуть, что все изложенное - результат моих собственных поисков, поэтому не исключено, что какое-то решение от Microsoft для обработки событий параллельного порта в режиме пользователя существует. Если кто-нибудь знает, поделитесь. В конце статьи я укажу E-mail для связи.

Рассмотрим по порядку все коды операций доступные в качестве аргумента в вызове DeviceIoControl при работе с LPTx. Код представляет собой 32-разрядное слово и строится по определенному правилу, которое учитывает тип устройства, вид и метод доступа. Не вдаваясь в подробности, перечислим значения кодов для управления параллельным портом и их идентификаторы, принятые в Microsoft.

Идентификатор кода IOCTL Значение кода
IOCTL_IEEE1284_GET_MODE 0014
IOCTL_IEEE1284_NEGOTIATE 0018
IOCTL_PAR_GET_DEFAULT_MODES 0028
IOCTL_PAR_GET_DEVICE_CAPS 0024
IOCTL_PAR_IS_PORT_FREE 0054
IOCTL_PAR_QUERY_DEVICE_ID 000C
IOCTL_PAR_QUERY_DEVICE_ID_SIZE 0010
IOCTL_PAR_QUERY_INFORMATION 0004
IOCTL_PAR_QUERY_LOCATION 0058
IOCTL_PAR_QUERY_RAW_DEVICE_ID 0030
IOCTL_PAR_SET_INFORMATION 0008
IOCTL_PAR_SET_READ_ADDRESS 0020
IOCTL_PAR_SET_WRITE_ADDRESS 001C
IOCTL_SERIAL_GET_TIMEOUTS B001C
IOCTL_SERIAL_SET_TIMEOUTS B0020

Обращаю внимание читателя, что статья приготовлена для сайта Delphi, поэтому все примеры кода и т.п. приводятся на Паскале. Коды в таблице указаны в шестнадцатеричном формате (т.е. 0х160014 и т.д.).

Код IOCTL_IEEE1284_GET_MODE предназначен для формирования запроса текущего режима порта:

const
IOCTL_IEEE1284_GET_MODE = 0014;

type
PARCLASS_NEGOTIATION_MASK = record
  usReadMask: word;
  usWriteMask: word;
end;
PPARCLASS_NEGOTIATION_MASK = ^PARCLASS_NEGOTIATION_MASK;

var
Mode: PARCLASS_NEGOTIATION_MASK;
lpOverlapped: POverlapped;
ret: DWORD;

DeviceIoControl(hLpt, IOCTL_IEEE1284_GET_MODE, nil, 0, @Mode,
                sizeof(PARCLASS_NEGOTIATION_MASK), ret, lpOverlapped);

Если запрос синхронный, то он выглядит так:

DeviceIoControl(hLpt, IOCTL_IEEE1284_GET_MODE, nil, 0, @Mode,
                sizeof(PARCLASS_NEGOTIATION_MASK), ret, nil);

@Mode - указатель на буфер, в котором драйвер возвращает текущий режим. Следующий параметр сообщает драйверу размер буфера, а параметр ret возвращает размер структуры, через которую передаются данные. Структура PARCLASS_NEGOTIATION_MASK имеет два поля: usReadMask - определяет режим работы порта при чтении, а usWriteMask - при записи. Вот возможные значения этих переменных:

const
	NONE	= {PAGE_TEXT}00;

// SPP modes
	CENTRONICS	= {PAGE_TEXT}01;	// Только для записи
	IEEE_COMPATIBILITY	= {PAGE_TEXT}02;	// Только для записи
	NIBBLE	= {PAGE_TEXT}04;	// Только для чтения
	CHANNEL_NIBBLE	= {PAGE_TEXT}08;	// Только для чтения
	BYTE_BIDIR	= {PAGE_TEXT}10;	// Только для чтения

// EPP modes
	EPP_HW	= {PAGE_TEXT}20;	// Аппаратный EPP
	EPP_SW	= {PAGE_TEXT}40;	// Программный EPP

// ECP modes
	BOUNDED_ECP	= {PAGE_TEXT}80;	// Упрощенный ECP
	ECP_HW_NOIRQ	= 00;	// Аппаратный ECP без IRQ
	ECP_HW_IRQ	= 00;	// Аппаратный ECP с IRQ
	ECP_SW	= 00;	// Программный ECP

Сразу после того, как порт открыт, он устанавливается в режим CENTRONICS по записи и NIBBLE по чтению.

Код операции IOCTL_IEEE1284_NEGOTIATE предназначен для согласования режима работы порта и устройства:

const
IOCTL_IEEE1284_NEGOTIATE = 0018;

var
ReqMode, LptMode: PARCLASS_NEGOTIATION_MASK;

ReqMode. usReadMask:= FF;	// Тестировать все режимы для чтения
ReqMode. usWriteMask:= FF;	// Тестировать все режимы для записи
DeviceIoControl(hLpt, IOCTL_IEEE1284_NEGOTIATE,
	@ReqMode, sizeof(PARCLASS_NEGOTIATION_MASK),
	@LptMode, sizeof(PARCLASS_NEGOTIATION_MASK),
	ret, lpOverlapped);

@ReqMode указывает на структуру PARCLASS_NEGOTIATION_MASK, которая содержит маску тестирования режимов для работы с устройством. Маска представляет собой произвольную сумму, указанных выше констант.

Драйвер выполняет последовательность согласования с устройством для каждого указанного в маске режима в соответствии со спецификацией IEEE 1284. Если соответствующий бит в маске выставлен в 1, то данный режим проверяется на возможность его применения при работе с подключенным устройством, а затем выбирается режим с максимальной пропускной способностью. Этот режим устанавливается в качестве текущего, а соответствующие ему значения драйвер возвращает в структуре LptMode. Кроме указанных выше констант, однозначно обозначающих режимы, Windows определяет еще две маски для запроса:

EPP_ANY	= {PAGE_TEXT}60;	// любой из EPP
ECP_ANY	= 80;	// любой из ECP

Чтобы запросить проверку всех режимов, нужно указать FF.

После согласования режима драйвер не переводит выходные линии порта в состояние, соответствующее выбранному режиму! Он оставляет их в состоянии инициализации:

Сигнал Конт. Состояние
nSelectIn(1284 Active) 17 низкий
nAutoFeed 4 высокий
nStrobe 1 высокий
Initialize 16 высокий

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

Код IOCTL_PAR_GET_DEFAULT_MODES применяется для запроса режима драйвера по умолчанию: CENTRONICS для записи и NONE для чтения. Может быть возможны и другие варианты, поэтому приведу команду целиком.

const
IOCTL_PAR_GET_DEFAULT_MODES = 0028;

var
Mode: PARCLASS_NEGOTIATION_MASK;

DeviceIoControl(hLpt, IOCTL_PAR_GET_DEFAULT_MODES, nil, 0, @Mode,
                sizeof(PARCLASS_NEGOTIATION_MASK), ret, lpOverlapped);

В структуре Mode возвращается режим по умолчанию в той же манере, что и для запроса текущего режима IOCTL_IEEE1284_GET_MODE.

Код IOCTL_PAR_GET_DEVICE_CAPS формирует запрос поддержки устройством различных режимов параллельного порта. Майкрософт заявляет о поддержке их драйвером спецификации IEEE1284, хотя есть немало существенных исключений, если доверять сайту экспертов этого протокола http://www.fapo.com/. К сожалению, у меня нет на руках самого документа, потому что IEEE просит за него около 0, поэтому дать 100% гарантию, что Майкрософт не прав не могу. Если у кого-то есть pdf оригинала, поделитесь с народом. Я говорю про IEEE1284.3 от 1994 года. А то без исходников происходит вечная путаница. Спецификация IEEE1284 определяет пять основных режимов:

  • совместимости (Centronics);
  • полубайтный (Nibble);
  • байтный (Byte_Bidir);
  • EPP;
  • ECP.

Майкрософт добавляет несколько подрежимов:

  • адресуемый полубайтный (CHANNEL_NIBBLE);
  • программный EPP (EPP_SW);
  • программный ECP (ECP_SW);
  • упрощенный ECP (BOUNDED_ECP).

Запрос с кодом IOCTL_PAR_GET_DEVICE_CAPS похож на IOCTL_IEEE1284_NEGOTIATE с той разницей, что он не меняет режим драйвера, а только проводит циклы согласования с устройством для всех(!!!) режимов, чтобы выяснить какие из них поддерживаются. Затем результат проверки возвращается в уже известной структуре PARCLASS_NEGOTIATION_MASK.

const
IOCTL_PAR_GET_DEVICE_CAPS = 0024;

var
LptMode: PARCLASS_NEGOTIATION_MASK;

DeviceIoControl(hLpt, IOCTL_PAR_GET_DEVICE_CAPS, nil, 0, @LptMode,
                sizeof(PARCLASS_NEGOTIATION_MASK), ret, lpOverlapped);

Если выдать этот запрос на пустой порт (без устройства), то будет отмечена поддержка только CENTRONICS и IEEE_COMPATIBILITY. Соответственно и включить никакие другие режимы будет невозможно. Исключение составляет только NIBBLE, который согласно спецификации должен поддерживаться всеми IEEE1284-устройствами. Чтобы использовать другой режим, например EPP, необходимо подключить устройство, поддерживающее последовательность согласования для этого режима. В ходе цикла согласования хост выставляет на шину данных байт совместимости, который уникален для каждого режима:

EPP		01000000
ECP		00x10000
byte mode	00000001

Затем по отрицательному переходу сигнала Paper End (конт. 12) хост проверяет уровень на линии Select (конт. 13). Если устройство подтверждает режим, то на эту линию оно должно выставить 1, в противном случае - 0. Байт совместимости имеет очень удобную структуру для его аппаратной обработки: за каждым режимом закреплен определенный бит, который выставляется в единицу только для этого режима. Поэтому, скажем для EPP, достаточно защелкнуть 6 бит (считая от 0) по сигналу nStrobe (1 конт.) и вернуть его по линии Select. Если сигнал Select просто навсегда установить в 1 (подтянуть к +5В), то устройство будет подтверждать все режимы.

Что касается других сигналов, то для согласования работы с драйвером параллельного порта достаточно на линях nError (конт. 15) и Paper End выставить лог. 1, а для формирования сигнала завершения цикла согласования можно по линии nACK (конт. 10) вернуть в хост уровень nAutoFeed (конт. 14). Так как оборванный вход LPT-порта воспринимается как 1, то для реализации этой последовательности достаточно просто воткнуть в разъем перемычку на контакты 10 и 14. Такое "устройство" драйвер распознает как поддерживающее следующие режимы: CENTRONICS, IEEE_COMPATIBILITY, NIBBLE, CHANNEL_NIBBLE, BYTE_BIDIR, EPP_SW, - т.е. все режимы, кроме аппаратного EPP и всех разновидностей ECP. Дело в том, что согласование ECP (который, кстати, придуман Microsoft в сотрудничестве с HP) требует более сложной последовательности согласования, и простой аппаратурой здесь не обойтись. В интернете свободно распространяется файл ecp_reg.pdf от Microsoft с подробным описанием режима.

Ситуация с аппаратным EPP еще более оригинальная: при запросе этого режима на разъеме порта……..ничего не происходит!!! А драйвер сообщает, что режим не поддерживается. Отсутствие даже намека на попытку согласования EPP_HW позволяет сделать вывод о том, что именно сам драйвер не поддерживает этот режим,- возможно из-за каких-то трений между Microsoft и Intel - автором EPP, может из-за технических нестыковок. Ниже сопоставляются сигналы в режимах аппаратного и программного EPP, а также применение соответствующих линий в цикле согласования.

№ конт. Centronics Аппарат. EPP Прогр. EPP Согласование
1 nStrobe nWrite nWrite nStrobe
10 nAck Interrupt ? nAck
11 Busy nWait nWait -
12 Paper End - - PE
13 Select - - Select
14 nAutoFeed Data Strobe Data Strobe nAutoFeed
15 nError - - nError
16 nInitialize nReset Address Strobe -
17 nSelectPrinter Address Strobe 1284 Active 1284 Active

Здесь надо обратить внимание на две вещи. Во-первых, в программной реализации EPP Майкрософт случайно или намеренно перепутал сигналы местами: строб адреса теперь на контакте 16 разъема, т.е. он соответствует сигналу nInitialize. На контакте 17 остается признак интерфейса 1284, как и во всех остальных режимах, включая последовательность согласования. Этот сигнал можно использовать как ChipSelect (активный высокий). Сигнала сброса nReset как такового нет, но есть последовательность сброса, которую выполняет драйвер по соответствующему запросу (см. далее). Во-вторых, из-за ограниченности набора сигналов одни и те же линии используются как в циклах квитирования режимов, так и в цикле согласования (например, Data Strobe - AutoFeed, Write - Strobe). Поэтому если не принять определенных мер, то во время последовательности согласования будет сымитирован цикл записи, и в регистр устройства будет записан байт совместимости, что не всегда допустимо. Мало того, непосредственно перед этим имитируется чтение данных из устройства, несмотря на наличие байта на выходе LPT. Работа двух устройств на выход одновременно не сулит ничего хорошего. В моем случае внешнее устройство "перетягивало" порт, изменяя байт совместимости. В худшем случае что-нибудь может выгореть.

Последовательность согласования предусматривает возможность запроса идентификатора устройства, который представляет собой нуль-терминальную строку. Эту строку Windows выдает на экран при обнаружении нового устройства. Чтобы сделать запрос ID, придется воспользоваться кодами IOCTL_PAR_QUERY_DEVICE_ID, IOCTL_PAR_QUERY_DEVICE_ID_SIZE, IOCTL_PAR_QUERY_RAW_DEVICE_ID.

С помощью кода IOCTL_PAR_QUERY_INFORMATION у драйвера можно запросить информацию об его состоянии.

const
IOCTL_PAR_QUERY_INFORMATION = 0004;

type
PAR_QUERY_INFORMATION = record
  Status: byte;
end;
PPAR_QUERY_INFORMATION = ^PAR_QUERY_INFORMATION;

var
ParInfo: PAR_QUERY_INFORMATION;

DeviceIoControl(hLpt, IOCTL_PAR_QUERY_INFORMATION, nil, 0,
                @ParInfo, sizeof(PAR_QUERY_INFORMATION),
                ret, lpOverlapped);

В поле ParInfo.Status драйвер возвращает байт, который может представлять собой комбинацию следующих чисел

const
	PARALLEL_INIT 	= ;
	PARALLEL_AUTOFEED	= ;
	PARALLEL_PAPER_EMPTY	= ;
	PARALLEL_OFF_LINE	= ;
	PARALLEL_POWER_OFF	= ;
	PARALLEL_NOT_CONNECTED	= ;
	PARALLEL_BUSY	= ;
	PARALLEL_SELECTED	= ;

Прямого соответствия между битами ParInfo.Status и физическими регистрами параллельного порта нет, но какая-то нетривиальная взаимосвязь существует: определенные комбинации входных сигналов на разъеме порождают различное состояние поля статуса драйвера.

Код IOCTL_PAR_SET_INFORMATION применяется для сброса драйвера.

const
IOCTL_PAR_SET_INFORMATION = 0008;

type
PAR_SET_INFORMATION = record
  Init: byte;
end;
PPAR_SET_INFORMATION = ^PAR_SET_INFORMATION;

var
ParControl: PAR_SET_INFORMATION;

ParControl.Init := PARALLEL_INIT;
DeviceIoControl(hLpt, IOCTL_PAR_SET_INFORMATION, @ParControl,
                sizeof(PAR_SET_INFORMATION), nil, 0, ret, lpOverlapped);

Обратите внимание на значение, которое присваивается переменной ParControl.Init. Других вариантов не существует. При таком запросе драйвер сбрасывает nSelectIn (1284 Active) в ноль, а на линию nInitialize (Address Strobe) выдает отрицательный строб. При этом сигнал nStrobe (Write) сохраняет высокий уровень. Такая последовательность чем-то похожа на цикл чтения адреса EPP, которая на практике редко используется. Поэтому по условию

  • nStrobe (конт. 1) - высокий;
  • nInitialize (конт. 16) - низкий;
  • nSelectIn (конт. 17) - низкий;

можно сформировать сигнал сброса устройства.

Запрос IOCTL_SERIAL_SET_TIMEOUTS устанавливает значение таймаута, которое драйвер использует в операциях записи в режимах SPP и ECP_SW. Прочитать установленное значение можно с помощью IOCTL_SERIAL_GET_TIMEOUTS.

3 ШАГ

Чтобы сформировать цикл записи адреса EPP, нужно воспользоваться кодом IOCTL_PAR_SET_WRITE_ADDRESS.

const
IOCTL_PAR_SET_WRITE_ADDRESS = 001C;

var
Address: byte;

Address := $AA;
DeviceIoControl(hLpt, IOCTL_PAR_SET_WRITE_ADDRESS, @Address, 1, nil, 0,
                ret, lpOverlapped);

Новый цикл будет сформирован лишь при условии, что в очередном запросе драйверу будет передан новый адрес. Драйвер пытается устранять избыточные операции - благая цель, которая усложняет систему и как следствие влечет ошибки. Я полагаю, что программист Майкрософт не правильно понял смысл чтения адреса EPP, поэтому такая операция отсутствует, зато есть команда IOCTL_PAR_SET_READ_ADDRESS, которая не формирует физических циклов, но настраивает драйвер таким образом, что он создает цикл записи адреса непосредственно перед циклом чтения данных. При этом драйвер, оптимизируя циклы адреса, не всегда работает разумно. Поэтому, не вдаваясь в подробности, я не рекомендую вообще использовать IOCTL_PAR_SET_READ_ADDRESS, тем более что он не дает ничего дополнительно по сравнению с IOCTL_PAR_SET_WRITE_ADDRESS.

Для записи данных в устройство используется функция WriteFile, а для чтения ReadFile. Обе функции обрабатывают поток данных, а не отдельный байт. Это означает, что для режима EPP будет последовательно сформировано столько циклов записи (чтения) данных, сколько необходимо, чтобы передать весь массив байт за байтом. Естественно, что все данные при этом уйдут по одному адресу. Ниже приводится пример кода, в котором производится запись по адресу $AA, а затем чтение по адресам $AA и $BB.

uses
Windows;

var
hLpt: THandle;
 ret: DWORD;
Address: byte;
Data: byte;

// Открыть порт для синхронного доступа
hLpt := CreateFile('LPT1', GENERIC_READ or GENERIC_WRITE, 0, nil,
        OPEN_EXISTING, 0, 0);

// Запись  по адресу $AA
Address := $AA;
DeviceIoControl(hLpt, IOCTL_PAR_SET_WRITE_ADDRESS, @Address, 1, nil, 0,
                ret, nil);
WriteFile(hLpt, [], 1, ret, nil);

// Чтение по адресу $AA
ReadFile(hLPT, Data, 1, ret, nil);

// Чтение по адресу $BB
Address := $BB;
DeviceIoControl(hLpt, IOCTL_PAR_SET_WRITE_ADDRESS, @Address, 1, nil,
                0, ret, nil);
ReadFile(hLPT, Data, 1, ret, nil);

// Закрыть устройство
CloseHandle(hLpt);

4 ШАГ

Закрывается устройство обычным образом: с помощью функции CloseHandle.