Первая публикация 30.12.2017
Меня всегда интересовала связь между реализацией языка программирования высокого уровня и операционной средой. Конкретно, через какие именно WinAPI реализуется связь языка с операционной системой Windows?
На первый взгляд, связь со средой в каждой конкретной программе своя и зависит от назначения программы. Например, для программы с «оконным» интерфейсом связь с Windows будет через многочисленные «оконные» API, а связь для программы, рисующей графику, – через API GDI и т.д.
Но среди этих тысяч API, вероятно, можно выделить некоторый минимальный базовый набор, который реализует собственно язык, а не прикладные программы. Определить этот базовый набор легко, когда компилятор работает по следующей схеме: операторы языка переводятся сразу в «нативные» команды процессора, а если таких команд нет (они являются слишком громоздкой последовательностью «простых» команд или выполняют взаимодействие с «внешним миром»), то компилятор генерирует системный вызов, т.е. вызов подпрограммы из системной библиотеки, являющейся, неотъемлемой частью языка.
В таком случае надо просто найти все вызовы API из системной библиотеки. И хотя, чтобы перейти в другую операционную среду может быть недостаточно только замены этих вызовов, (именно потому, что в программах может быть взаимодействие со средой не только через системную библиотеку), это все же дает хорошее понимание привязки реализации языка программирования к конкретной операционной системе. А значит, становятся ясными и действия по переводу данной системы программирования в другую среду.
Интуитивно понятно, что чем сложнее исходный язык, тем больше API потребуется для его реализации. Но поскольку каждое API обычно простое с точки зрения выполняемого действия, перевод в другую среду потребует замены API на другие, но тоже сравнительно простые по выполняемым действиям API. Например, во всех средах есть, конечно, чтение из файла с указанием, сколько и куда в память читать байты. И хотя параметры такого API в разных средах могут задаться совершенно по-разному, логически перевести в другую среду каждое API не представляется сложным делом.
Вызовы API в системной библиотеке
У языка высокого уровня PL/1-KT, компилятор которого реализован для среды Win64, я проанализировал все вызовы WinAPI в системной библиотеке. Оказалось, что могут быть вызваны 27 разных API (см. таблицу).
AllocConsole | FormatMessage | GetStdHandle | Sleep |
CharToOEM | GetCommandLine | GlobalMemoryStatus | TerminateThread |
CloseHandle | GetCurrentDirectory | MapViewOfFile | UnMapViewOfFile |
CreateFile | GetFileSize | ReadFile | VirtualAlloc |
CreateFileMapping | GetLastError | RTLAddFunctionTable | VirtualFree |
CreateThread | GetLocalTime | SetFilePointer | WriteFile |
ExitProcess | GetShortPathName | SetUnhandledExceptionFilter |
Однако остается вопрос, насколько необходим каждый вызов именно для самого языка. На мой взгляд, здесь должен быть применен принцип «вещей альпиниста» – не то, что может пригодиться, а то, без чего нельзя обойтись.
Поэтому пришлось еще проанализировать каждый вызов API, на предмет, какое именно свойство языка оно реализует. Ниже приведены результаты этого анализа с заключением, насколько необходимо для реализации языка наличие каждого API, причем они следуют уже не в алфавитном порядке, а разбиты по функциональным группам.
Подключение стандартного окна ввода-вывода
В языке PL/1 определен стандартный (текстовый) ввод-вывод, поэтому потребовались API AllocConsole и GetStdHandle. Вообще говоря, подключение текстовой «консоли» может быть определено в самом формате PE-файла, поэтому ее вызов вряд ли так необходим. А вот API, определяющее стандартные файлы ввода-вывода (в PL/1 это файлы SYSIN и SYSPRINT), совершенно необходимо для реализации.
Выделение памяти
В PL/1 имеется аппарат работы с памятью в виде операторов ALLOCATE/FREE. Однако три следующих API GlobalMemoryStatus, VirtualAlloc и VirtualFree используются только один раз в начале работы программы для определения и задания стартовых размеров «кучи» для последующей работы ALLOCATE/FREE. Интересно, что эта «куча» уже первоначально может быть несвязной и составленной из списка несмежных сегментов (из-за загрузки системных библиотек). При этом максимально возможный суммарный размер кучи, определяется рекурсивными вызовами VirtualAlloc/VirtualFree, т.е. методом проб и ошибок. А определение физического размера памяти в компьютере нужно только для реализации одного специального режима, когда «куча» не должна превосходить размер физического ОЗУ (для повышения эффективности). Таким образом, для реализации нужна лишь одна VirtualAlloc, а две другие обеспечивают пусть и полезные, но избыточные возможности.
Работа с файлами
Развитые средства файлового обмена в PL/1 сводятся к вызовам лишь шести API: CloseHandle, CreateFile, GetFileSize, ReadFile, WriteFile и SetFilePointer. Можно проследить однозначную связь между этими API и операторами обмена в PL/1. Некоторое исключение составляет GetFileSize, которое, во-первых, можно заменить использованием SetFilePointer, а во-вторых, оно нужно для обеспечения главным образом, перехвата исключительных ситуаций, хотя в данной реализации и имеется прямое ее использование оператором типа X=LENGTH(F), где X – целая переменная, а F – переменная типа «файл».
Несколько особняком стоят еще три API, реализующие «отображение файла на память»: CreateFileMapping, MapViewOfFile и UnMapViewOfFile. Это очень удобная возможность работы с файлами изначально, конечно, никак не была представлена в PL/1, хотя операторы вроде READ FILE(F) SET(P), где P – переменная типа указатель, являются прообразом такой работы.
Перехват исключений
Может быть, не все знают, что впервые обработка исключений появилась в PL/1 полвека назад. Естественно, в системной библиотеке есть вызов двух API на эту тему:
RTLAddFunctionTable и SetUnhandledExceptionFilter.
Причем необходимость использования обоих вызывает большие сомнения. Задать постоянный обработчик всех исключений проще прямо в формате PE-файла, а не с помощью RTLAddFunctionTable. А поскольку такое задание перехватчика обычно включает практически все случаи, второе API, по-моему, вообще не требуется.
Работа с параллельными потоками
Поскольку в PL/1 предусмотрено паралелльное выполнение с помощью оператора TASK, используются два API: CreateThread и TerminateThread.
TerminateThread потребовалось только для новой возможности прекращения потока, изначально отсутствующей в стандарте языка.
Параметр командной строки
В PL/1 предусмотрено, что главная (MAIN) подпрограмма может иметь параметр, для получения которого требуется API GetCommandLine.
Запрос времени
Встроенные функции DATE и TIME в PL/1 реализуются с помощью единственного API GetLocalTime.
Пауза
В стандарте PL/1 это довольно тонкое место, но действительно в языке предусмотрен оператор DELAY, который и реализуется с помощью API Sleep, а кроме того, пауза нужна для реализации оператора языка WAIT при выполнении параллельных потоков.
Оператор STOP
Данный оператор в PL/1 однозначно отображается с помощью API ExitProcess.
Вспомогательные API
В системной библиотеке PL/1 обнаружилось еще пять вызовов следующих API: CharToOEM, FormatMessage, GetCurrentDirectory, GetLastError и GetShortPathName.
Три API GetLastError, CharToOEM и FormatMessage дают развернутую информацию об ошибке, причем даже на русском языке. Хотя в PL/1 и предусмотрена стандартная реакция на исключения, для которых нет обработчиков (обычно это окончание работы с выводом сообщения), конкретный вид сообщений в стандарте PL/1, конечно, не оговорен и эти API не реализуют необходимые свойства языка. Хотя на практике информация от этих API, безусловно, помогает в поиске ошибок.
API GetCurrentDirectory и GetShortPathName выдают некоторую информацию о среде Windows и тоже напрямую не относятся к языку. Например, отголосок MS DOS в виде GetShortPathName позволяет просто сократить и стандартизировать информацию в случае ошибки работы с файлом, умещая ее в одну строку. Конечно, в другой среде строго повторять это не требуется.
Заключение
Итак, реализация языка довольно высокого уровня PL/1 (у многих до сих пор в головах остались мифы о его невероятной сложности) свелась к реализации всего лишь горстки API, в пределе следующей:
GetStdHandle, VirtualAlloc, CloseHandle, CreateFile, ReadFile, WriteFile, SetFilePointer, CreateThread, GetCommandLine, GetLocalTime, Sleep и ExitProcess.
Эти 12 API позволяют реализовать довольно развитые возможности, намекая при этом на минимально необходимый набор API в любой операционной системе, например, даже во вновь разрабатываемой.
Открытия здесь, конечно, никакого нет, но одно дело – абстрактные теоретические рассуждения, какие функции должна иметь среда, а другое – конкретная и уже проверенная на практике реализация.
Можно возразить, что «голый» язык на практике неудобен и неэффективен, например, какое-нибудь GetCurrentThread просто необходимо для установления аппаратных контрольных точек при отладке, а, значит, список API должен быть шире приведенного.
Но здесь речь идет именно о базовом наборе средств связи языка с внешним миром, который будет примерно одинаковым во всех универсальных операционных системах.