Вводная
В своей прошлой статье я не был полностью честен. Перед тем, как получить рабочее устройство, я много раз проверял как мой код работает, перезаписывая его на многоразовую флеш AT28С64. И с самого начала знал что отлаживаться придется на железе, а потому встал вопрос программатора параллельных EEPROM.
Некогда крайне востребованные, а ныне необходимые только для редких специфических задач, эти программаторы стоят неприлично дорого (на этот раз серьезно). Есть бюджетные варианты, например собрать такой программатор на основе ардуины (но не весело) или быстро изобрести решение самому (но лень писать софт).
Однако, у отца оказался программатор Omega. На самом деле это не совсем программатор, это базовый блок на основе которого, теоретически, можно собрать множество разных устройств используя разные адаптеры, но один из адаптеров (имеющихся в наличии) - это универсальный программатор Orange. Но есть одна небольшая загвоздка: у меня современные компьютеры с Windows 10 и Windows 11, а этот программатор использует LPT. И нужно было как-то из этой ситуации выходить.
Эта статья о том, как можно заставить работать на новом компьтере старый софт и старое железо, рассчитанные на связь через LPT, при этом не прибегая к изменению ни оригинальных исполняемых файлов, ни схемотехники устройства. В статье речь будет идти о программаторе Omega-Orange и поставляемого к нему софту, но все описанное актуально и для других программ с другими устройствами.
Обзор возможных решений
Некогда популярный, а ныне забытый LPT - очень удобный для программиста параллельный порт. Тем не менее, все еще можно встретить его на материнских платах в том или ином виде. Сразу рассмотрим возможные варианты подключения устройства с LPT к современному компьютеру.
Реальный LPT порт
Несмотря на довольно долгую поддержку на аппаратном уровне, последнее время LPT на материнских платах или не распаивали вовсе, или распаивали только разьем для подключения, вместо распайки полноценного DB25. Сейчас же его поддержку вырезали на аппаратном уровне, но все еще существуют актуальные материнские платы где он присутствует.
Плюсы:
Реальный LPT, адаптация софта не требуется.
Минусы:
Далеко не у всех есть, а дальше будет и того меньше.
Большая часть программ предполагает программирование этого порта через прямое обращение к портам ввода вывода (инструкции IN/OUT), чего винды просто так сделать не дадут. А ставить сторонний драйвер (не подписанный или подписанный слитым сертификатом) не все могут, а многие справедливо откажутся.
Шнурок USB-LPT
На рынке есть много производителей, предлагающих такое решение. Однако я не смог найти нормальную документацию на используемые чипы.
Плюсы:
Дешево.
USB есть у всех.
Минусы:
Это не настоящий LPT-порт, а некая его абстракция, с которой можно взаимодействовать только через WINAPI, и то не совсем понятна функциональность. Похоже, существует исключительно для поддержки древних принтеров.
Даже если в прошлом пункте я ошибся, и через WINAPI все же можно гибко шевелить таким виртуальным LPT - все еще необходима адаптация софта, потому что в пространстве IO он никак не будет отображен.
PCI-LPT адаптер
Активно существует и производится, как минимум китайской компанией WCH.
Плюсы:
Вполне себе реальный LPT-порт.
Минусы:
В ноутбук, увы, PCI не воткнешь.
Адреса ввода/вывода у такого порта будут сильно отличаться от стандартных (а в софте они, как правило, указаны жестко).
Так же актуален вопрос с драйверами, которые откроют доступ к IO.
В итоге решение, позволяющее подключить старое устройство с портом LPT к новому железу попросту отсутствует. У некоторых производителей есть проприетарные решения, предполагающие обновленный софт, но это не портируемые решения существующие только в области промышленного оборудования (а они ОЧЕНЬ не любят обновлять железо, некоторые до сих пор используют компьютеры PDP!). Или ищи старый компьютер и ставь его рядом с новым, или изобретай свое. Конечно же, я решил изобрести свое.
Особый подход
Итак, взявшись решить проблему самостоятельно, попутно придется изобрести пару велосипедов. Для начала, надо определиться с требованиями к решению. Я составил следующие:
Отсутствие потребности в особых драйверах.
Отсутствие необходимости изменять оригинальную программу.
Максимальная переносимость.
Может показаться что это слишком амбициозно для проблемы, которую еще никто почему-то не решил (или я не умею гуглить). Однако, план у меня есть.
Для начала надо разобраться с тем, что же такое LPT. Я начал свою практику когда LPT уже считался критически устаревшим, и тыкал его всего пару раз интереса ради, ограничиваясь записью в регистр 888. Но тут пришлось влезть в это дело глубже.
Что такое LPT
Это параллельный порт, претерпевший в ходе своей жизни несколько переработок. Оригинальный порт имел восемь линий данных (только вывод), пять линий статуса (только ввод) и четыре линии управления (только вывод). Еще у него было аж пять линий земли, но это не так важно.
Изначально предполагалось (обычно называется legacy или ISA), что это будет специальный порт для принтера. Собственно LPT — это Line Printer Terminal. Так как считывать с принтера нечего, то данные работали только на выход, линии статуса использовались для синхронизации и определения ошибок. Тем не менее, порт был настолько прост в программировании и удобен по своей структуре, что пользователи быстро начали создавать для него свои устройства, совершенно не похожие на принтеры. Но разработчики быстро столкнулись с нехваткой линий ввода, что делало считывание с устройств крайне неудобным.
Следующая версия (обычно называется BiDir или PS/2) была практически копией своего предшественника, но имела важное отличие: направление линий данных стало переключаемым, что позволило организовать очень удобную полудуплексную передачу данных. Однако, одна из проблем продолжающих существовать с прошлой версии: порт предполагал синхронизацию ввода/вывода, но не реализовывал ее аппаратно. А потому многие программисты игнорировали ее, полагаясь что скорость их кода сама собой будет синхронизацией, и в последствии, когда компьютеры стали быстрее, пользователи заимели много головной боли, пытаясь заставить работать свои устройства, которые теперь уже не успевали за компьютером. Нормальных решений проблеме отсутствия синхронизации не существовало, так что решали проблему чем могли, например использовали специальные программы замедляющие процессор, чтобы замедлить скорость работы IO.
Необходимость вручную считывать регистр статуса, проверять состояние отдельных бит, и в зависимости от них ждать дальше или править регистр управления была ключевой для грамотной работы LPT‑порта. Производитель решил избавить программистов от этого, и так появилась реализация LPT под названием EPP (Extended Parallel Port). Сохраняя полную совместимость с предыдущими версиями, он реализовал дополнительные регистры (адреса и данных), при записи и чтения из которых линии данных, статуса и управления переходили под контроль аппаратного обеспечения, автоматически выставляя нужную комбинацию для считывания и записи, и сами ожидали подтверждения готовности от ведомого. Это значительно упрощает работу, однако детальнее мы это рассмотрим позже.
В последствии была разработана еще одна версия LPT — ECP. Повысили скорость, добавили буферизацию, и вероятно что‑то еще. Однако, он меня совершенно не интересует на данный момент, потому что в документации к моему программатору сказано что он работает исключительно в режиме EPP.
И что с этим делать
Задачу можно разделить на два этапа:
Заставить программу поверить, что у меня существует реальный LPT-порт, и она может с ним работать. Требуется программное решение.
Заставить устройство поверить что программа взаимодействует с ним через LPT-порт. Требуется аппаратное решение.
Если кому‑то кажется странным, что я собираюсь заставить работать устаревшую программу — напомню, что у винды все очень хорошо с обратной совместимостью, а софт зачастую разрабатывался для Win9x/WinXP, и единственное что не дает ему нормально работать — это необходимость иметь доступ к пространству IO, где оно ожидает LPT‑порт.
Я принципиально не хочу патчить исходную программу, потому что крайне не люблю оставлять свои следы, которые могут в последствии самым неожиданным образом сказаться на работоспособности программы (я встречал программу, которая рассчитывала адрес функций, используя хеш‑сумму своего исполняемого файла). К тому же, пачинг сделает мое решение совершенно непереносимым. А значит, надо найти способ перехватывать обращения к IO не изменяя программу.
И несмотря на то, что программу изменять я не буду, никогда не лишним будет узнать что у нее внутри. По какой‑то причине разработчик выложил на сайте программу в зашифрованном виде, и ключ к архиву выдает исключительно по запросу. Мне не очень понятен этот ход, но раз уж он так решил — не буду выкладывать внутренности программы, ограничусь скриншотами и описаниями отдельных частей.
При запуске программа выдает ошибку загрузки драйвера. Что это за драйвер — можно догадаться по лежащему в папке с программой WinIo.sys. Это один множества драйверов, которые активно использовались для доступа к пространству IO в эпоху, когда подпись у драйвера была опциональной фичей. Работали они все одинаково: программа их загружала, потом отправляла запрос на доступ к портам, а драйвер ей этот доступ выдавал. В связи с особенностью устройства линейки Windows NT, права доступа к пространству IO одни на все запущенные программы, что не очень‑то и безопасно (как и загрузка стороннего драйвера). В Linux это реализовано проще и удобнее, но это другая история.
WinIo вместе с исходниками был доступен с сайта http://www.internals.com/ (а сейчас доступен через вебархив), и для программы представлял собой библиотеку с десятью функциями:
bool _stdcall InitializeWinIo();
void _stdcall ShutdownWinIo();
bool _stdcall InstallWinIoDriver(PSTR pszWinIoDriverPath, bool IsDemandLoaded);
bool _stdcall RemoveWinIoDriver();
bool _stdcall GetPortVal(WORD wPortAddr, PDWORD pdwPortVal, BYTE bSize);
bool _stdcall SetPortVal(WORD wPortAddr, DWORD dwPortVal, BYTE bSize);
bool _stdcall GetPhysLong(PBYTE pbPhysAddr, PDWORD pdwPhysVal);
bool _stdcall SetPhysLong(PBYTE pbPhysAddr, DWORD dwPhysVal);
PBYTE _stdcall MapPhysToLin(PBYTE pbPhysAddr, DWORD dwPhysSize, HANDLE *pPhysicalMemoryHandle);
bool _stdcall UnmapPhysicalMemory(HANDLE PhysicalMemoryHandle, PBYTE pbLinAddr);
Нас интересует InitializeWinIo
, которая проверяет что драйвер запущен, и запускает его если он не запущен, и функции GetPortVal
/SetPortVal
, через который осуществляется доступ к портам.
Когда я увидел что WinIO предполагается в виде сторонней библиотеки — хотел порадоваться что все дело обойдется подменой dll. Однако, в данном случае используется статическая линковка.
Перехватить межмодульные вызовы несложно, и второй моей идеей было перехватывать обращения к драйверу через перехват вызова DeviceIoControl. Идея многообещающая, посмотрим что говорит документация к WinIO:
Place winio.dll, winio.vxd and winio.sys in the directory where your application's executable file resides.
Add winio.lib to your project file by right clicking on the project name in the Visual C++ workview pane and selecting "Add Files to Project...".
Add the #include "winio.h" statement to your source file.
Call InitializeWinIo.
Call the library's functions to access I/O ports and physical memory.
Call ShutdownWinIo.
Тут все логично. Кидаем два файла драйвера (для NT и для Win9x) и библиотеку, инициализируем, используем функции для доступа к портам и памяти. Не знаю зачем честному человеку могло потребоваться использовать MapPhysToLin
/UnmapPhysicalMemory
/GetPhysLong
/SetPhysLong
, но прямой доступ к физической памяти затея в целом нездоровая и небезопасная (хотя и крайне веселая). Возможно, для любителей что-то рисовать на экране минуя графический драйвера винды.
Так же есть заметка относительно InitializeWinIo
:
Under Windows NT/2000/XP, calling InitializeWinIo grants the application full access to the I/O address space. Following a call to this function, an application is free to use the _inp/_outp functions provided by the C run-time library to access I/O ports on the system.
И это уже куда менее веселая новость. Перехватывать исполнение инструкций не так просто, как перехватывать межмодульные вызовы. Нужно убедиться что разработчик использует вызовы к WinIO, вместо простого вызова _inp/_outp
.
Впрочем, разочарование наступило когда присмотрелся к самим функциями GetPortVal
/SetPortVal
. Они проверяли версию системы, и если система была NT - то тоже использовали прямой вызов _inp/_outp
.
bool IsWinNT()
{
OSVERSIONINFO OSVersionInfo;
OSVersionInfo.dwOSVersionInfoSize = sizeof(OSVERSIONINFO);
GetVersionEx(&OSVersionInfo);
return OSVersionInfo.dwPlatformId == VER_PLATFORM_WIN32_NT;
}
Конечно, можно было бы перехватить и GetVersionEx
и подменить значение, но это уже совершенно неспортивно. К тому же, программа вызывает GetVersionEx
много раз, и подмена всех значений могла привести к неопределенным последствиям. Альтернативно — можно закладываться на адрес возврата, и относительно него определять какое значение необходимо вернуть. Но мне такая идея совершенно не понравилась.
Перехват вызовов от программы
Итак, нам нужно перехватить межмодульные вызовы чтобы программа запустилась, а затем перехватить выполнение инструкций IN/OUT чтобы эмулировать LPT.
Первая часть работы тривиальна: достаточно использовать один из множества способов перехвата межмодульных вызовов. Например, использовать библиотеку detours от самих майкрософт. Что приятно, с момента моего последнего использования этой библиотеки прошло много времени, и она успела стать опенсорсной https://github.com/microsoft/Detours.
Итак, что мы делаем:
Создаем Dll, которая будет выполнять перехват
Создаем лаунчер, который запустит процесс и встроит в него мою DLL
Внутри Dll перехватываются вызовы CreateFile, подменяя хендл создаваемый для
\\.\WINIO
Так же перехватываем вызов DeviceIoControl с обращением к хендлу созданному в прошлом шаге, имитируя наличие драйвера
В целом, все просто. Не вижу смысла углубляться в детали, их можно посмотреть в исходниках. После того как я имитировал положительный ответ от драйвера, и приложение запустилось - оказалось что приложение не может обнаружить LPT (в основном потому что его и правда нет). Как оно это делает? Чтобы это понять я воспользовался API Monitor от rohitab. Изначально ожидалось что программа обращается к SetupAPI, но оказалось что оно проверяет реестр в разделе HKLM\HARDWARE\DEVICEMAP\PARALLEL PORTS, пытаясь вычитать оттуда список LPT-портов. Причем программе не так важно что именно она там найдет. Она честно пытается распарсить найденное значение, но на практике оказалось что в паре ключ-значение shit=pants
успешно обнаруживается наличие LPT0. Конечно, можно было бы создать какое-то такое значение на работающей системе, но некрасиво будет оставлять такие артефакты, к тому же я уже вошел во вкус при перехвате вызовов, так что и проблему решим перехватом.
Нужно перехватить:
RegOpenKeyExA ( HKEY_LOCAL_MACHINE, "HARDWARE\DEVICEMAP\PARALLEL PORTS", ...)
RegEnumValueA от предыдущего хендла
RegQueryValueExA с запросом на возвращенное во втором шаге значение
Теперь программа запускается. На этом заканчивается специфическая для моей программы часть, все описанное далее применимо к любой программе работающей с LPT.
Перехват инструкций IN/OUT через AddVectoredExceptionHandler
Можно отлавливать системные исключения при выполнении инструкций чтения портов и обрабатывать их, это элегантно и делается довольно просто, через регистрацию своего обработчика с помощью AddVectoredExceptionHandler и написания в нем простенького декодера инструкций. Конечно же, через удаленный поток или в той же встроенной Dll. Это не так сложно, тем более что инструкций всего 12 (чтение и запись, по три (8, 16, 32) на прямое указание порта и на указание порта через DX). В теории, тут всегда должна быть запись/чтение по 8, но мало ли. К тому же, эти инструкции не обновляют флагов. Однако, есть и минусы:
The handler should not call functions that acquire synchronization objects or allocate memory, because this can cause problems. Typically, the handler will simply access the exception record and return.
Технически, это предупреждение может ничего и не значить, но на практике именно из за игнорирования таких предупреждений и возникают проблемы с переносимостью.
Перехват инструкций IN/OUT отладчиком
Альтернативный вариант - запустить процесс в режиме отладки, а затем отлавливать исключения EXCEPTION_PRIV_INSTRUCTION. Когда такое исключение получено - определить какая именно инструкция ее вызвала, и имитировать ее исполнение, изменив по необходимости значения регистров данных и исправив EIP. Чтобы запустить процесс в режиме отладки достаточно создать его с флагом DEBUG_PROCESS, а затем ловить уведомления от него через WaitForDebugEvent, обрабатывать их, и возвращать управление через ContinueDebugEvent.
Примерно так:
while (process_alive && WaitForDebugEvent(&de, INFINITE))
{
DWORD continue_type = DBG_CONTINUE;
switch (de.dwDebugEventCode)
{
case CREATE_PROCESS_DEBUG_EVENT:
{
CloseHandle(de.u.CreateProcessInfo.hFile);
}
break;
case LOAD_DLL_DEBUG_EVENT:
{
CloseHandle(de.u.LoadDll.hFile);
}
break;
case EXCEPTION_DEBUG_EVENT:
{
switch (de.u.Exception.ExceptionRecord.ExceptionCode)
{
case EXCEPTION_PRIV_INSTRUCTION:
{
HANDLE thread = OpenThread(THREAD_ALL_ACCESS, FALSE, de.dwThreadId);
if (!process_io_exception(pi.hProcess, thread, de.u.Exception.ExceptionRecord.ExceptionAddress))
{
continue_type = DBG_EXCEPTION_NOT_HANDLED;
}
}
break;
default:
{
continue_type = DBG_EXCEPTION_NOT_HANDLED;
}
break;
}
}
break;
case EXIT_PROCESS_DEBUG_EVENT:
{
process_alive = false;
}
break;
default:
{
continue_type = DBG_EXCEPTION_NOT_HANDLED;
}
break;
}
ContinueDebugEvent(de.dwProcessId, de.dwThreadId, continue_type);
}
Тут вызывается функция process_io_exception, которая определяет что именно за инструкция вызвала исключение, и если это наша ожидаемая IN/OUT — обрабатывает ее. Если что‑то неожиданное — не обрабатывает.
Так как перехватываемых инструкций не так много — я написал небольшой дизассемблер. Звучит громко, хотя было реализовано лишь это:
#define FILL_INSTRUCTION_DATA(_instruction_sz, _port, _io_size, _out_direction) \
{\
instruction_sz = (_instruction_sz);\
port = (_port); \
io_size = (_io_size);\
out_direction = (_out_direction);\
}
#define CHECK_OPERATION_2B(byte0, byte1, _instruction_sz, _port, _io_size, _out_direction) \
if(instr_ptr[0] == (byte0) && instr_ptr[1] == (byte1)) FILL_INSTRUCTION_DATA(_instruction_sz, _port, _io_size, _out_direction)
#define CHECK_OPERATION_1B(byte0, _instruction_sz, _port, _io_size, _out_direction) \
if(instr_ptr[0] == (byte0)) FILL_INSTRUCTION_DATA(_instruction_sz, _port, _io_size, _out_direction)
bool process_io_exception(HANDLE process, HANDLE thread, void* exception_address)
{
uint8_t instr_ptr[16]; //bytes readed from exception ptr
uint8_t instruction_sz; //instruction length, bytes
uint16_t port; //port number
uint8_t io_size; //io data size, bits
bool out_direction; //1 if OUT, 0 if IN
uint32_t edx = 0; //ExceptionInfo->ContextRecord->Edx
uint32_t eax = 0; //ExceptionInfo->ContextRecord->Eax
CONTEXT threadContext = { .ContextFlags = WOW64_CONTEXT_INTEGER | WOW64_CONTEXT_CONTROL };
//<...>
SIZE_T readed;
if (!ReadProcessMemory(process, exception_address, instr_ptr, sizeof(instr_ptr), &readed) || readed != sizeof(instr_ptr))
{
return false;
}
if (!GetThreadContext(thread, &threadContext))
{
return false;
}
edx = threadContext.Edx;
eax = threadContext.Eax;
CHECK_OPERATION_2B(0x66, 0xE5, 3, instr_ptr[2], 16, false) //IN 16 indirect
else CHECK_OPERATION_2B(0x66, 0xED, 2, edx & 0xFFFF, 16, false) //IN 16 DX
else CHECK_OPERATION_2B(0x66, 0xE7, 3, instr_ptr[2], 16, true) //OUT 16 indirect
else CHECK_OPERATION_2B(0x66, 0xEF, 2, edx & 0xFFFF, 16, true) //OUT 16 DX
else CHECK_OPERATION_1B(0xE4, 2, instr_ptr[1], 8, false) //IN 8 indirect
else CHECK_OPERATION_1B(0xE5, 2, instr_ptr[1], 32, false) //IN 32 indirect
else CHECK_OPERATION_1B(0xEC, 1, edx & 0xFFFF, 8, false) //IN 8 DX
else CHECK_OPERATION_1B(0xED, 1, edx & 0xFFFF, 32, false) //IN 32 DX
else CHECK_OPERATION_1B(0xE6, 2, instr_ptr[1], 8, true) //OUT 8 indirect
else CHECK_OPERATION_1B(0xE7, 2, instr_ptr[1], 32, true) //OUT 32 indirect
else CHECK_OPERATION_1B(0xEE, 1, edx & 0xFFFF, 8, true) //OUT 8 DX
else CHECK_OPERATION_1B(0xEF, 1, edx & 0xFFFF, 32, true) //OUT 32 DX
else
{
return false;
}
//<...>
threadContext.Eip += instruction_sz; //move EIP +n bytes
threadContext.Eax = eax;
if (!SetThreadContext(thread, &threadContext))
{
return false;
}
}
Тут можно задать вопрос: а как же INS/OUTS? И уж тем более REP INS/OUTS? А никак. Добавить их поддержку можно, но не очень-то и нужно, так что пока обойдемся без них. Следующий вопрос - зачем я отлавливаю передачи размером в 16 и 32 байта, если реально обрабатываю только восьмибитные? Для полноты картины и для упрощения расширения функционала в дальнейшем.
Тем не менее, инструкции перехватываются, декомпилируются, программа считает что они работают, а это уже победа. Для начала просто игнорировались инструкции записи, а при чтении всегда возвращался ноль. Программа запустилась! "Находит" в реестре запись о порте LPT, а затем исправно начинает мучать порты IO (которые на этом этапе у меня просто логгировались в файл) и не получая от устройства ответа выдает ошибку. Заглянув в полученный файл с логом, я увидел кучу ожидаемых обращений к портам 0x37A/0x378/0x379, но помимо них так же заметил запись в порт 0x77A. О таком я услышал впервые, и погуглив, а нагуглить сейчас информацию о настолько устаревших технологиях непросто, обнаружил что этот регистр никак не упоминается в большинстве списков. Например, в списке портов реализованных в BOCHS. И это только добавило вопросов. Благо вспомнил о существовании Ralf brown interrupt list, который в своем оригинальном виде включает так же и список портов. И он говорит следующее: PORT 0778-077A - Intel 82091AA - ECP-mode PARALLEL PORT
. Я плохо представляю себе тонкости работы с железом тех лет, и не уверен совместимы ли реализации ECP разных производителей, но все дальшенаписанное будет основано на документации к 82091AA. Порт 0x077A - конфигурационный порт LPT-ECP, и туда пишется значение 0x20. Значение 0x20 настраивает DATA на вход. Почему это делается именно там - не знаю, но могу предположить что это фикс для специфичной ошибки какого-то чипсета. А значит - можно просто игнорировать обращения к этому порту.
Осталось не так много - реализовать аппаратную часть.
Аппаратная часть
Что такое LPT-порт и какой он бывает уже определились. И как уже упомянул - готовые решения в виде USB-LPT шнурков не предоставляют достаточно документации, чтобы использовать их для эмуляции LPT-порта. Так что реализую свою версию.
Для реализации я выбрал микроконтроллер ATMEGA8. Почему ее? Она пятивольтовая (а LPT использует напряжения TTL, примерно 3-5 вольт), есть удобная программная реализация USB, выпускается в DIP (удобно для пайки прототипов), доступна, я умею ею пользоваться и главное - она у меня есть. Для прототипа самое то, а если по какой-то причине понадобиться еще - можно будет оперативно все это портировать на нормальный контроллер с аппаратным усб и ценой в два десятка центов, либо переразвести с ней же, но в более компактном и дешевом корпусе.
Схема получилась такой:
Тут самый минимум всего того, что может быть нужно. Я даже не стал добавлять защитных резисторов на выход LPT. Оригинальные LPT их зачастую не имели, и с удовольствием дохли от любого слегка завышенного тока, так что будем считать это дополнительным уровнем совместимости. Все необходимые для программирования пины, за исключением RESET, я использовал на разъеме, а значит для перепрошивки микроконтроллера достаточно вытащить наружу RESET, а все остальное можно взять с разъема.
Получилось сделать настолько уродливо, насколько и планировалось. В принципе, можно сделать кастомную плату, которая поместится в корпус, на край которой будет напаян LPT, а по сторонам расположены все те же детальки в микроскопических корпусах. Будет очень красиво, удобно для распайки и даже дешевле. Однако это все идеализм который требует много времени, а это всего лишь прототип.
Как известно, залог хорошего продукта - удобные средства разработки. Так что для перепрошивки контроллера я собрал вот такой адаптер под стандартный разьем AVR-ISP.
Осталось малое — написать прошивку, и для этого нужно:
Поднять USB - делается очень просто с помощью V-USB
Реализовать интерфейс LPT
Заставить работать без драйверов
Первый пункт очень прост — достаточно скопировать исходники в свой проект и USB поднимется.
Для реализации LPT надо разобраться как программировали оригинальный LPT.
Версии ISA и PS/2 имели всего три регистра:
base+0: PDATA — регистр порта данных, полудуплексная шина. У PS/2 направление определяется битом в PCON.5.
base+1: PSTAT — регистр статуса, содержит статус LPT порта и его линий. Можно вычитать: состояние линий BUSY, ACK#, PERROR, SELECT и FAULT#.
base+2: PCON — регистр настройки порта и управляющими линиями. У PS/2 настраивает направление PDATA, прерывания от LPT порта, линии SELECTIN#, INIT#, AUTOFD# и STROBE#.
У EPP есть еще два регистра:
base+3: ADDSTR — регистр, при записи/чтения данных в который генерируется последовательность передачи адреса.
base+4: DATASTR — четыре регистра с общим названием при записи/чтения в которые генерируется последовательность передачи данных.
Теоретически, EPP имеет сразу четыре регистра DATASTR, но практически определен только первый из них, а функциональность оставшихся трех зависит от реализации. Будем считать что в моей реализации их нет. А если регистры ADDSTR/DATASTR не использовать - то работать EPP будет точно как версия PS/2.
Сразу нужно заметить что некоторые линии инвертированы, а именно:
PDATA — при чтении и записи использует прямую трансляцию, то есть 1 в регистре соответствует 1 на лини.
PSTAT — ACK#, PERROR, SELECT, FAULT# — используют прямую трансляцию, а BUSY — обратную
PCON — STROBE#, AUTOFD#, SELECTIN# — используют обратную трансляцию, а INIT# — прямую.
Этого достаточно чтобы реализовать LPT. Я начал с того, что реализовал только режимы Legacy и PS/2. Не смотря на документацию к программатору он может работать не только с EPP, но так же и в режиме PS/2, так что для проверки работоспособности идеи этого будет достаточно.
Для усб-устройства (которое я гордо назвал AVRUSBLPT, или кратко AVRLPT) достаточно следующих команд:
AvrLpt_SetMode
— выбор режима совместимости.AvrLpt_SetReg
— запись в указанный регистр.AvrLpt_GetReg
— чтение из указанного регистра. Все остальные команды, если они и будут — служебные (вроде AvrLpt_GetVersion)
Для обмена данными с AVRLPT я выбрал USB vendor control transfer. Углубляться не буду, скажу только что это наиболее простой способ реализации передачи сообщения по USB.
Следующая задача — отсутствие необходимости в драйвере. Решается элементарно, достаточно использовать драйвер WinUSB, который винда подгрузит автоматически если обнаружит на устройстве особый дескриптор под названием WCID. Это нестандартное расширение интерфейса USB от майкрософт, позволяющее винде использовать универсальный драйвер для устройства без необходимости согласовывать это с пользователем. Очень хорошо и подробно WCID описан тут: https://github.com/pbatard/libwdi/wiki/WCID‑Devices
Замечу следующее: WinUSB условно поддерживается начиная с Windows XP SP2 (хотя если у вас Windows XP — то и LPT наверняка есть), нормально поддерживается начиная с windows 8. За основу для своего устройства я использовал код отсюда: https://github.com/mariusgreuel/USBasp/
В итоге получилось устройство которое могло вести себя как LPT, подключается по USB, не требует драйвера и может работать в режимах Legacy и PS/2. Как ни странно, все заработало и даже фирменный софт смог увидеть программатор! Прогрмамматор периодически чудил, а когда не чудил то просто крайне медленно работал. Я не знаю что в этом обвинить как не проблемы с синхронизацией, вызванные использованием нестабильных задержек. Нужно было реализовать EPP.
Итак, EPP. Все довольно просто. При записи в регистр ADDSTR/DATASTR происходит следующее:
Хост выставляет Write# в 0
Хост выставляет данные на шину DATA
Хост выставляет data strobe# опускается в 0 (если это DATASTR) или addr strobe# в 0 (если это ADDSTR)
Устройство выставляет Wait# в 1
Хост выставляет data strobe# в 1 (если это DATASTR) или addr strobe# в 1 (если это ADDSTR)
Хост выставляет Write# в 1
Устройство выставляет Wait# в 0
Увы, разные производители документируют эту последовательность по разному. Ниже две последовательности: из документации Intel и из документации National Semiconductor. Разница ощутимая, но я предпочел второй алгоритм.
Все бы хорошо и понятно, но остаются вопросы о таймингах и таймаутах. Совершенно не ясно какие задержки должны быть реализованы, через какое время считать что данные не переданы, как сигнализировать об ошибке и как обрабатывать ситуацию если в начале передачи Wait# изначально высокий. В интернете есть упоминания таймаутов в 5, 10 или 15 мкс, возьму за основу 10. Так же вызывает вопрос предварительная инициализация линий порта. Судя по всему, это зависит от реализации. Так же кое‑где упоминается что младший бит PSTAT может быть флагом таймаута, но в документации на 82 091 этого нет. Пришлось откопать документацию на PC87 338 (другая реализация SuperIO) и посмотреть там. Там он описан так: действует только при EPP, в нормальном режиме 0, если произошел таймаут — устанавливается в 1, и сбрасывается при чтении. Так и реализую.
После реализации EPP глюки пропали, а скорость работы значительно выросла, хотя и осталось далекой от ожидаемой.
Первая мысль — буферизировать операции вывода, но на практике большая часть обращений к портам это поллинг регистра PSTAT, а операции записи идущие друг за другом последовательно — явление крайне редкое.
Ускоряем работу перехвата IN/OUT
Тест показал что обработка одного исключения, не считая обращения к AVRLPT занимает примерно 101 мкс, а вместе с обращением — уже около 381 мкс. То есть если исключить из этого время обработки прерываний — то каждое обращение к порту будет занимать около 280 мкс. Все еще много, но уже лучше. А при условии что такие обращения происходят тысячами — выигрыш во времени (на треть быстрее!) уже заметный.
Как я говорил, патчить исполняемый файл очень сильно не хочется — это не только лишает решение портируемости (на потенциальные новые версии этой же программы) и универсальности (на другие программы), но и чрезвычайно скучно.
Другое дело — патчить сразу в памяти. Отлавливаем обращение к IO, проверяем размер, создаем процессу‑жертве новый клок памяти, куда копируем нужный код и по адресу где произошло исключение вставляем заплатку, вызывающую мой патч. Так как вызов по 32-х битному адресу занимает 5 байт, а инструкция чтения/записи не более 3 — придется еще и часть инструкций переносить.
Финальный штрих, патчинг программы прямо в памяти. План дествий такой:
В Dll, которая подгружается в процесс, добавляем функции работы с AVRLPT
После подгрузки этой Dll получаем адреса искомых функций
После запуска процесса ждем исключений UNPRIVILEDGED INSTRUCTION
По адресу исключения определяем точный тип инструкции IN/OUT, вырезаем ее
Дизассемблером длин определяем длину следующей инструкции, повторяем пока освободившегося места не хватит для LONG JMP
Все вырезанные инструкции копируем в буфер
Добавляем в этот же буфер код обработки IO
Добавляем в этот же буфер адреса функций работы с AVRLPT
Добавляем в этот же буфер LONG JMP обратно на место исключения + длина LONG JMP
Буфер внедряем в адресное пространство обрабатываемого процесса
На месте с вырезанными инструкциями добавляем LONG JMP на ранее подготовленный буфер, при необходимости дополняем инструкциями NOP
Дизассемблер длин был использован этот: https://github.com/greenbender/lend, но немного доработан для предотвращения выхода за пределы буфера.
В конце концов нашелся плюс от того что EIP нельзя использовать как адресный регистр. Однако, если вызов был в конце одной функции, сразу за которой начинается другая, то такой патч испортит вызов следующей функции. Будем надеяться что такое происходит не слишком часто, потому что однозначного способа предотвратить это я не вижу. Аналогичная проблема возникнет если выше будет осуществляться условный переход на адреса следующие прямо за вызовом IN/OUT. Теоретически, можно реализовать алгоритм поиска свободного места и адаптивным патчингом, но это огромный пласт работы который я делать не хочу. Остается лишь надеяться что применимо к программе программатора этот метод будет работать не создавая ошибок, но всегда можно откатиться на версию осуществляющую перехват без патчинга.
Может показаться что логичнее было бы не использовать два LONG JMP и уникальный кусок кода для каждого вызова, а ограничится CALL и универсальными функциями для ввода и для вывода. Однако, мы копируем себе следующую за IN/OUT инструкцию, и это вполне может оказаться инструкция работы со стеком, так что стоит оставить стек в том виде, в котором он ожидается.
Это решение замедлит обработку первого исключения по каждому из адресов, но в последствии IO будет работать быстрее. Можно, конечно, представить себе синтетическую ситуацию, где обрабатываемый процесс постоянно создает новые адресные пространства с вызовами IO, и тогда получится что мы генерируем код для генерированного кода, что приведет к неконтролируемому нарастанию потребления памяти в целевом процессе.
Инструкцию LONG JMP можно осуществить только со сменой страницы, и мне не хочется вникать в вопрос всегда ли винда использует для кода одну и ту же страницу, так что эту инструкцию отбрасываем. Зато в те же шесть байт помещается PUSH DWORD+RET, что по сути тот же LONG JMP, но без смены страницы. Его и использовал.
После реализации метода с патчингом в памяти скорость обмена данными увеличилась и программу теперь использовать довольно комфортно. Однако скорость выросла не так сильно как хотелось бы, и зависания интерфейса остались, так как разработчик использует один поток на все операции. В моем случае скорость программы ограничивается искусственными (и сильно завышенными) задержками, которые используются при обращении к микросхеме памяти.
Патчинг в памяти, как бы он ни был хорош в моем случае, может сломать программу в прочих случаях. А потому я на всякий случай оставил возможность собрать инжектор в режиме обработки при перехвате — это определяется флагом препроцессора MEMORY_PATCH_MODE
.
Вывод: адаптировать таким образом старые приложения под новые реалии можно, но скорость обмена данными страдает. И это может быть фатально в тех случаях, когда разработчик не реализовал синхронизацию (а это, увы, случается, хотя и не мой случай). Приборы, рассчитывающие на скорость обмена данных, вроде логических анализаторов втыкаемых в LPT работать не будут.
Исходники, как водится, на гитхабе: https://github.com/a-sakharov/LptToUsbAdapter
Вывод
Используя описанный метод можно подключить устройство рассчитанное на работу с LPT-портом используя LPT-USB адаптер, а мой софт сможет перехватить "сырые" обращения к LPT и перенаправить их через адаптер. Скорость работы немного пострадает, но это не должно быть критично в большинстве случаев. Да, большинству устаревших устройств можно дать вторую жизнь. А если очень хочется - то можно даже снова что-то разработать под LPT.
Послесловие
Важные замечания:
VID/PID у моего USBLPT используются нелегально, запрос на их выделение отправлю в ближайшем будущем.
Мое решение может потребовать незначительной доработки (ECP, INS/OUTS, REP INS/OUTS) для использования с другими программами, использующими LPT. Я мог бы заняться и этим, но для решения моей задачи это излишне.
В статье, на схеме и в прошивке может наблюдаться некоторая неразбериха в названиях выводов LPT‑порта, связанная с тем, что разные версии имеют разные названия, и некоторые названия хоть и не официальные — но прижившиеся. В разных случаях я использую разные названия, но на схеме устройства есть табличка проясняющая этот момент.
Если обнаружится что какой‑то из многочисленных USB‑LPT адаптеров позволяет реализовать все то, что я реализовал через AVRLPT — один из велосипедов можно будет исключить, оставив лишь самое важное — пехеват и перенаправление.