Все вызовы WinAPI внутри системной библиотеки PL/1-KT

Коли нужен чёрт, то и ступай к чёрту
Тому не нужно далеко ходить, у кого чёрт за плечами
Н.В. Гоголь «Ночь перед Рождеством»

Позапрошлую заметку я начинал словами «вот уже 10 лет прошло…», а эту можно было бы начать «вот уже 20 лет прошло…». Хотя там речь шла лишь о выравнивании стека, а здесь – о целой организации взаимодействия программы с WinAPI. Помнится, здесь недавно в комментариях кто-то наивно удивлялся: зачем вы приводите устаревший и никому не интересный способ программирования через WinAPI? А как же иначе программа вообще может взаимодействовать со средой Windows, как не через вызовы ее стандартных функций? Через имеющиеся надстройки над WinAPI не все можно сделать.

Конечно, было бы прекрасно все время оставаться только в рамках парадигмы используемого языка программирования и чтобы «на фотографии не торчали уши фотографа», т.е. чтобы в исходных текстах никак не проявлялись бы особенности взаимодействия со средой. Например, в большинстве языков есть понятие файла. Чтобы открыть файл не обязательно явно описывать стандартную функцию из WinAPI CreateFile или OpenFile, поскольку компилятор переведет встроенный в язык оператор открытия или прямо к обращению к этой функции или к вызову системной библиотеки, которая где-то внутри себя и вызовет требуемую функцию. В любом случае программист не обязан знать, как именно это реализовано в Windows.

В системной библиотеке языка PL/1-KT, который я использую, имеется обращение лишь к 28 функциям WinAPI и это вполне покрывает «обычные» возможности языка и можно было бы не заботиться о явных вызовах. Но увы, часто этого мало. И хотя нормальные люди ходят в двери, а не в окна (ах, какая свежая, искрометная шутка!), приходится в программах явно обращаться к функциям типа CreateWindow или CloseWindow. А это уже ну никак не входит в понятия языка.

Таким образом, для максимально полного использования возможностей среды Windows в серьёзном языке требуется и механизм явного обращения к WinAPI.

Когда я только приступал к переводу своей системы программирования «под Windows» вместо MS-DOS, задача вызова WinAPI казалась простой и не требующей введения каких-то добавлений в сам язык. С его такими-то богатыми возможностями! С раздельной трансляцией и операторами описания процедур из других модулей. Вызвал, да и все. Но, увы, как я не бился, обойтись существующими операторами и ключевыми словами языка не удалось. Ну не было во времена Шекспира сигарет «Друг», то есть, тьфу, не было во времена разработки PL/1 таких динамически подключаемых библиотек. А все WinAPI реализованы именно так.

Поясню в чем дело. Ранее редактор связей объединял все объектные модули и библиотеки (или только требуемые части библиотек) в единый выполняемый модуль. Никакой другой информации, кроме имени внешней функции или процедуры редактору связей не требовалось. Сам же файл библиотеки или «подцеплялся» по умолчанию или его имя явно указывалось в списке объединяемых модулей.

Для механизма же DLL как раз сам файл библиотеки на этапе объединения объектных модулей в выполняемую программу не требуется. В специальном разделе каждого EXE-файла, который называется «секция импорта» сохраняется информация об именах библиотек, которые потребуется загрузить в память до начала работы программы. В этой же секции хранятся имена требуемых внешних подпрограмм из этих библиотек и ссылки на специальную таблицу адресов. Когда загрузчик Windows готовит программу к запуску, он считывает в память указанные в этой секции DLL-библиотеки, по именам находит адреса указанных внешних подпрограмм и функций и подставляет эти адреса в указанные места запускаемого EXE-файла. Причем если, например, функция из DLL-библиотеки в программе вызывается в 100 местах, это не значит, что ее адрес тоже будет подставлен в EXE-файле в 100 местах. На самом деле адрес будет подставлен только в единственном месте, поскольку DLL подразумевает косвенную адресацию. Т.е. имеется только одна переменная, содержащая адрес функции, и именно эта переменная настраивается перед стартом программы и затем используется во всех командах вызова функции.

В этой части PL/1 оказался на высоте, поскольку имеет объекты типа ENTRY VARIABLE, т.е. как раз косвенные вызовы через переменные, содержащие адрес функций. Значит, вызов WinAPI с точки зрения PL/1 это вызов ENTRY VARIABLE и никаких доработок не требуется.

Однако в общем случае в исходных текстах на языке PL/1 не удалось различить «обычные» внешние функции, информация о которых исчезает после работы редактора связи, и DLL-функции, информацию о которых нужно записать в секцию импорта. Пришлось вводить новое ключевое слово IMPORT в описание процедур и функций. Это плохо и не соответствует стандарту, но что делать. Небольшим утешением является то, что это новое ключевое слово автоматически добавляет процедуре атрибут VARIABLE и поэтому хоть его писать в исходных текстах не требуется.

Первый уровень борьбы с WinAPI был пройден, и теперь в исходных текстах на PL/1 уже можно было писать что-нибудь вроде:

dcl CloseHandle entry(fixed(31)) import;

Но до победы было еще далеко.

Следующая задача – как указывать в исходных текстах имена библиотек? Я попробовал несколько вариантов и не один не понравился. Не понравился, главным, образом, тем, что нужно помнить, где какая функция находится или тащить в программу всю кучу заголовочных файлов. Поэтому я решил вообще никак не писать в исходных текстах имена библиотек, а создать специальный объектный модуль в составе системной библиотеки, где будет указано, какая функция какой библиотеке принадлежит.

Чтобы было меньше переделок, воспользовался возможностью используемого ассемблера RASM-KT передавать с помощью директивы PUBLIC не только адреса подпрограмм и переменных, но и просто константы.

Исходный текст модуля на ассемблере выглядит так:

; ==================== KERNEL32@DLL ====================

PUBLIC KERNEL32@DLL EQU 01 SHL 16

PUBLIC ACQUIRESRWLOCKEXCLUSIVE EQU KERNEL32@DLL+1
AcquireSRWLockExclusive EQU NOT ACQUIRESRWLOCKEXCLUSIVE

PUBLIC ACQUIRESRWLOCKSHARED EQU KERNEL32@DLL+2
AcquireSRWLockShared EQU NOT ACQUIRESRWLOCKSHARED

PUBLIC ACTIVATEACTCTX EQU KERNEL32@DLL+3
ActivateActCtx EQU NOT ACTIVATEACTCTX

PUBLIC ACTIVATEACTCTXWORKER EQU KERNEL32@DLL+4
ActivateActCtxWorker EQU NOT ACTIVATEACTCTXWORKER

PUBLIC ADDATOMA EQU KERNEL32@DLL+5
AddAtomA EQU NOT ADDATOMA

PUBLIC ADDATOMW EQU KERNEL32@DLL+6
AddAtomW EQU NOT ADDATOMW

PUBLIC ADDCONSOLEALIASA EQU KERNEL32@DLL+7
AddConsoleAliasA EQU NOT ADDCONSOLEALIASA

PUBLIC ADDCONSOLEALIASW EQU KERNEL32@DLL+8
AddConsoleAliasW EQU NOT ADDCONSOLEALIASW
...

; ==================== MAPI32@DLL ====================

PUBLIC MAPI32@DLL EQU 16 SHL 16

PUBLIC BMAPIADDRESS EQU MAPI32@DLL+1
BMAPIAddress EQU NOT BMAPIADDRESS

PUBLIC BMAPIDETAILS EQU MAPI32@DLL+2
BMAPIDetails EQU NOT BMAPIDETAILS

PUBLIC BMAPIFINDNEXT EQU MAPI32@DLL+3
BMAPIFindNext EQU NOT BMAPIFINDNEXT
...

Честно говоря, выглядит как-то непонятно, но если присмотреться, то оказывается, что здесь именам библиотек просто ставятся в соответствие числовые константы, имеющие 16 младших нулевых бит. А именам функций в этих библиотеках – константы с такой же старшей частью и обычным порядковым номером в младшей части.

Получив на входе такой странный объектный модуль (его я назвал, конечно, «Windows»), редактор связей просто создает свою внутреннюю таблицу, где каждому имени соответствует некоторое число. Затем, обрабатывая уже обычные объектные модули, редактор связей ищет имена внешних ссылок и в этой таблице и в других модулях. Если это была обычная функция, она найдется в одном из модулей, будет определен ее адрес и этот адрес будет подставлен в нужные места будущего EXE-файла (с помощью FIXUPP см. предыдущую заметку). Если же имя найдено в данной таблице, редактор связей переносит это имя и значение в заготовку будущей секции импорта.

Обратите внимание, что в таблице присутствуют два имени: одно большими буквами, другое – и большими и малыми. Дело в том, что PL/1 не различает большие и малые буквы вне символьных констант в кавычках. А PL/1-KT не различает также и совпадающие русские и латинские. Поэтому, кроме имени по правилам WinAPI присутствует и его «псевдоним» по правилам PL/1. Чтобы не плодить новых констант значению «истинного» имени по правилам WinAPI присваивается отрицательное значение константы имени-псевдонима. Попутно решается и проблема длины имени, которая в PL/1 не должна превышать 31 символа. С помощью псевдонима реальное длинное имя WinAPI можно укоротить или даже заменить на русское. Впрочем, русское название вызова WinAPI неудобно, из-за того, что документация оперирует лишь английскими именами.

Также обратите внимание, что на этом этапе наличие в описаниях ключевого слова IMPORT никак не учитывается. Оно нужно лишь на этапе компиляции, чтобы правильно обрабатывать аргументы. А WinAPI это или нет, определяет просто сам факт нахождения имени-псевдонима в данной таблице. Если очередное имя в этой таблице не найдено, то и соответствующая переменная косвенного вызова останется с нулевым значением. Системная библиотека уже самого PL/1 перед собственно запуском программы проверит и заполнит все нулевые значения таких переменных адресом подпрограммы выдачи сигнала об ошибке. Т.е., если ошиблись в имени вызова WinAPI и его в системной таблице редактора нет – в точке вызова произойдет перехватываемое исключение с определенным номером.

Такая система подключения к программе вызовов WinAPI кажется странной, но на практике она оказалось вполне удобной. Дело в том, что вся система WinAPI (в виде Win32) довольно консервативна и меняется редко. За десятилетия работы мне потребовалось всего полтора десятка стандартных библиотек Windows: Kerlel32, User32, GDI32, Shell32, WinMM, ImageHlp, OLE32, NTDLL, WinInet, AdvAPI32, ComCTL32, GDIPlus, WinHTTP, Wsock32, MAPI32, которые содержат суммарно около 9600 различных вызовов. Большая часть этих вызовов почти никогда не используется, но в таблице они автоматически записаны все.

Ну, а если это какая-то другая, или вообще сторонняя библиотека, например DirectX, как быть? В этом случае мы обычно используем «старую добрую» WinAPI LoadLibrary для динамической загрузки во время исполнения программы. Здесь можно писать в кавычках уже любое имя, необязательно по правилам PL/1, и здесь явно указывается библиотека. Строго говоря, вызовом только этой функции можно было бы вообще заменить весь описанный выше механизм, но тогда бы исходные тексты выглядели бы очень громоздко и безобразно.

Таким образом, имеющаяся реализация языка PL/1 потребовала минимальных переделок, чтобы было возможно почти свободно пользоваться WinAPI и при этом исходные тексты почти не отражают работу в этой среде. Потребовалось только одно новое ключевое слово IMPORT, главным образом, чтобы аргументы вызываемой функции обрабатывались правильно (т.е. по правилам ABI Windows), в исходной реализации PL/1-KT правила передачи аргументов не соответствовали ABI Windows.

Потребовался также механизм псевдонимов, чтобы обойти ограничения на длину и регистр букв в именах PL/1. Однако этот механизм фактически прозрачен для программиста, хотя некоторые «уши фотографа» все-таки торчат в виде букв A и W в конце имен WinAPI, предназначенных для работы со строками или в ANSII или в юникоде. Здесь их приходится указывать явно.

С помощью несложной программы была автоматически составлена на языке ассемблера специальная таблица, отображающая все связи между именами WinAPI и библиотеками, в которых они находятся, а также между истинными именами и их псевдонимами, учитывающими ограничения имен в PL/1. Эта таблица позволила при написании исходных текстов не задумываться, в какой библиотеке находится требуемая функция.

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

В результате программирование на PL/1 (реализация в виде PL/1-KT) стало возможным и с помощью явных вызовов WinAPI, причем необходимость в обязательных заголовочных файлах, обычно очень большого объема, отпадает, поскольку можно описывать только используемые функции непосредственно в тексте программы и не указывая источник-библиотеку.

Вот типичный пример: требуется для обычной расчетной программы выводить в заголовок консольного окна Windows процент выполненного расчета, который в программе содержится, например, в переменной I. В программе я лишь добавляю одну строку описания соответствующего вызова и ввожу переменную-строку S:

dcl SetConsoleTitleA entry(char(*) var) import;
dcl I fixed(31);
dcl S char(*) var;

//---- преобразование числа в текст ----
   put string(S) edit('Выполнено ',I,'%^@')(a,f(2),a);

//---- собственно вывод в заголовок консольного окна ---- 
   SetConsoleTitleA(S);

Примечание: символы ^@ задают нулевой код в символьной константе в кавычках.

Кстати, здесь я могу писать имена как угодно большими и малыми буквами, компилятор все равно переведет все имена в большие и латинские. Но при работе редактора связей в EXE-файл будет помещено правильное имя вместо псевдонима. Поскольку в данном случае из всех WinAPI требуется только одна функция, можно и описать только ее одну, а не тащить какие-либо заголовочные файлы. Ну, и, разумеется, не указывать библиотеку KERNEL32.DLL.

В случае обратной задачи, когда я сам пишу DLL-библиотеку на PL/1, опять возникают ограничения на имена функций. Но в этом случае помогает еще одна небольшая доработка языка: после атрибута EXTERNAL в описании функции можно добавить "внешнее" имя, различающее большие и малые буквы, например:.

MY_FUNCTION: PROCEDURE EXTERNAL('MyFunction');

Т.е. внутри программы используется имя большими буквами, но для внешник программ, использующих эту DLL-библиотеку, оно будет видно как MyFunction.