Использование компилятора Delphi (dcc32.exe) в прикладных программах
Хотя в названии статьи тема выглядит довольно узко, я хотел бы рассказать не только об использовании dcc32, но и о технологии, которой я дал условное название "многозвенное программирование", хотя вынести это название в заголовок статьи мне показалось неправильным. Какой смысл я вкладываю в термин "многозвенное программирование"? Начну издалека. Работу над более или менее большими программами можно разделить на два крупных этапа. Первый этап - это собственно разработка, которая включает формулировку технического задания, увязку требований заказчика, проектную фазу, итеративное уточнение структуры проекта, программирование, отладку и тестирование. Первый этап заканчивается выпуском первой версии и началом эксплуатации программы у заказчика (или у массы пользователей, если программа была разработана по собственной инициативе для распространения или продажи). Затем наступает этап сопровождения, который включает устранение обнаруживаемых ошибок, адаптацию к постоянно изменяющимся требованиям заказчика, ввод дополнительных возможностей, которые не были оговорены в исходном задании. Часто бывает так - за время сопровождения программа претерпевает настолько существенные изменения, что сопровождение ее становится делом гораздо более трудоемким и хлопотным, чем разработка.
Если первый этап достаточно хорошо поддерживается разнообразным программистским инструментарием, то второй этап в этом смысле поддержан значительно хуже. Главной целью "многозвенного программирования" как раз и является поддержка этапа сопровождения. В чем основная идея этого подхода? Рассмотрим простую схему:
разработчик <---> заказчикВ этой схеме заказчик использует только те функциональные возможности программы, которые предоставлены разработчиком. Для изменения этих возможностей заказчик выставляет требования разработчику, разработчик изменяет программу и возвращает заказчику. Таким образом, при интенсивном изменении требований к программе, у разработчика всегда большая загрузка, а у заказчика постоянно тормозится работа. Рассмотрим другую схему:
разработчик <---> технолог <---> пользовательВ этой схеме заказчик образно разделяется на две составляющие - технолог и пользователь. Под технологом здесь понимается человек (или группа), который является посредником между разработчиком и пользователем. Технолог профессионально владеет той предметной областью, для которой разработана программа, но не является программистом - это может быть энергетик, астроном, режиссер. Причем, такое разделение заказчика может быть чисто условным - один и тот же человек может выполнять функции, как технолога, так и конечного пользователя. Технолог - это ключевое звено в цепочке. Технолог знает предметную область значительно лучше разработчика и, весьма часто, хотел бы изменить функционирование программы так, как не было предусмотрено программистом. Частые обращения к разработчику могут быть весьма затруднительными - как во времени, так и в пространстве.
Для улучшения этой ситуации можно сделать следующее - передать часть работы программиста технологу. Поскольку технолог по определению не является программистом, нужна дополнительная связующая часть. Такой связующей частью может быть проблемно-ориентированный язык, который разработчик включает в свой проект и которым технолог может воспользоваться для изменения функциональности программы (в разумных пределах). Естественно, что этот язык должен оперировать терминами той предметной области, в которой работает технолог. То есть, между формулировкой задачи и языком ее решения нужен минимальный семантический разрыв. Универсальные языки программирования на эту роль явно не подойдут. Внешней синтаксической формой проблемно-ориентированного языка может быть текст, граф, схема, короче то, на чем технолог наиболее адекватно формулирует свои конкретные задачи. Таким образом, интенсивность взаимодействия между разработчиком и технологом может быть уменьшена, так как значительную часть изменений технолог может делать самостоятельно.
Эта идея используется многими разработчиками, но в литературе я не встречал ее обсуждения как инструмента для сопровождения. В этом смысле вместо термина "многозвенное программирование" обычно используется термин "проблемно-ориентированный язык".
В многозвенной структуре, которую я нарисовал выше, содержится только 2 программирующих звена, но в реальности этих звеньев может быть больше. Если предметная область достаточно разнородна, то "технолог" может быть целой цепочкой технологов - конкретный пример я приведу в самом конце статьи.
Конечно, здесь есть и другая сторона медали - маркетинговые соображения. Если заказчик получит развиваемый инструмент, то разработчик может остаться в проигрыше. И чем лучше инструмент, тем менее вероятно, что заказчик будет оплачивать сопровождение программы. Здесь уже решать разработчику - или заниматься только сопровождением старых программ или высвобождать время для новых разработок.
Вопросы реализации.
Вот собственно то, что я хотел сказать о технологии "многозвенного программирования". Теперь практическая часть. Как можно реализовать такую технологию? Один из наиболее используемых вариантов - это встраивание в программу интерпретатора проблемно-ориентированного языка. Встраивание компилятора я встречал значительно реже. В своей программистской практике я использовал оба варианта. Достоинства интерпретатора очевидны, но также очевидны и недостатки. Общий принцип состоит в том, что интерпретатор либо каждый раз интерпретирует исходное представление программы на проблемно-ориентированном языке, либо порождает промежуточный код, исполняемый некоторой виртуальной машиной. Примеры - пакет Microsoft Office с языком VB for Applications, реализация языка Java. Сюда же относятся все скриптовые языки. Если требуется межплатформенная совместимость, то интерпретаторы, вероятно, будут лучшим вариантом. Если же более важны соображения эффективности, то компиляция оказывается более предпочтительным выбором. Общий принцип - берем описание задачи на проблемно-ориентированном языке, генерируем соответствующий код на некотором универсальном языке, компилируем его и выполняем.
В этом разделе статьи я расскажу о втором варианте - использование компилятора, а конкретно, dcc32.exe из поставки Delphi (хотя можно использовать любой другой быстрый и качественный компилятор для любого другого подходящего языка).
Далее я рассмотрю этапы, которые нужно сделать, чтобы воплотить замыслы технолога. Конкретности реализации демонстрируется на примере небольшой библиотеки, которую прилагаю к статье (называется она DccUsing) и крошечного проекта под незатейливым именем DccExamples.
1. Генерация кода
На основе исходного представления, которое формулирует технолог, нужно сгенерировать код для компиляции. Исходное представление может быть любым, в простейшем случае - это обычный текст. В процессе генерации кода наибольшее внимание нужно уделить диагностике ошибок. То есть, ошибки желательно выявить во время генерации кода и генерировать уже синтаксически правильный код. Для этого можно использовать любые доступные методы, вплоть до синтаксических анализаторов с рекурсивным спуском - такие анализаторы достаточно просты и описаны во многих книгах, например у Бьерна Страуструпа в "Язык программирования C++" (Третье издание). Если есть возможность, то желательно контролировать также семантическую правильность. Далее я буду рассматривать только те моменты, которые являются общими для всех задач без учета их специфики.
Генерировать исходный текст можно любым способом, например, просто посылая строки текста в файл. Более удобный способ, как мне кажется, это направление текста в строко-ориентированный поток. Такой поток предоставляет дополнительное удобство при диагностике ошибок. Библиотека DccUsing содержит два потоковых класса: TFileCompileOut и TStringCompileOut, которые порождаются от TCompileOut. Классы очень просты, их реализацию можно посмотреть в исходном файле библиотеки, поэтому я дам только обзор. Базовый класс имеет методы:
public procedure IncLevel; procedure DecLevel; procedure AddSpace; procedure AddLine(const aLine: String); procedure AddLines(const aLines: array of String); procedure AddFile(const aFileName: String); procedure AddLineTemplate(const aLine: String; const aArgs: array of String); procedure AddLinesTemplate(const aLines, aArgs: array of String); procedure AddFileTemplate(const aFileName: String; const aArgs: array of String); procedure AddPoint(aPoint: Integer); function FindPoint(aLine: Integer): Integer; property Level: Integer read FLevel; property LinesCount: Integer read FLinesCount;
Первые три метода позволяют управлять форматированием кода. Хотя форматирование совсем не обязательно (код никто не читает), но дает удобства при отладке, а, кроме того, мне нравится, когда программа выглядит эстетично. IncLevel увеличивает отступ текста, DecLevel уменьшает, а AddSpace добавляет в поток пустую строку. Два следующих метода добавляют в поток соответственно строку и массив строк, а метод AddFile - весь указанный файл. Свойства позволяют узнать текущий уровень отступа и текущее число строк в потоке. Назначение методов AddPoint и FindPoint будет объяснено в разделе диагностики ошибок.
Методы AddLineTemplate, AddLinesTemplate и AddFileTemplate более сложны, чем предыдущие методы, представляют собой простые макропроцессоры и позволяют параметризовать генерируемый текст. Параметризующие аргументы - это массив строк, которые заменяют метасимволы в исходном тексте шаблона. Метасимволы выглядят так: {{X}}, где Х - это порядковый номер аргумента, начиная от 1. Макроподстановка производится без всякого учета лексики. Поэтому можно параметризовать все что угодно - идентификаторы, строки, комментарии, операторы и т.д. Например, если шаблон текста таков:
const tFunc: array[0..5] of String = ( 'function {{1}}.SortProc{{2}}(const a1, a2: {{2}}): Integer;', 'begin', ' if a2 > a1 then result := 1', ' else if a2 = a1 then result := 0', ' else result := -1;', 'end;' );
то при использовании
c.AddLinesTemplate(tFunc,['TTestClass1','Integer']);
мы получим такой результат:
function TTestClass1.SortProcInteger(const a1, a2: Integer): Integer; begin if a2 > a1 then result := 1 else if a2 = a1 then result := 0 else result := -1; end;
а при использовании
c.AddLinesTemplate(tFunc,['TTestClass2','String']);
такой:
function TTestClass2.SortProcString(const a1, a2: String): Integer; begin if a2 > a1 then result := 1 else if a2 = a1 then result := 0 else result := -1; end;
Наследуемые классы переопределяют абстрактную процедуру записи строки в поток и имеют специфические методы. Класс TFileCompileOut специализируется на построчном выводе в файл:
public constructor Create(const aFileName: String); destructor Destroy; override; property FileName: String read FFileName;
Конструктор принимает имя файла и открывает файл на чтение, а деструктор закрывает файл.
Класс TStringCompileOut хранит генерируемый текст в памяти:
public procedure Clear; procedure SaveToFile(const aFileName: String); procedure SaveToOut(aOut: TCompileOut); property Capacity: Integer ... property Items[aIndex: Integer]: String ... default;
Методы класса позволяют очистить поток, сохранить поток в файле и добавить его к другому потоку. Свойства позволяют изменить резервируемый объем памяти для списка строк и получить доступ на запись и чтение строк по индексу. Общее число строк определяет наследуемое свойство LinesCount. Примеры использования этих классов смотрите в DccExamples.pas.
Отметим, что часть неизменяемого или шаблонного кода может быть заготовлена заранее, располагаться в файлах и объединяться в нужных местах результирующего кода с помощью AddFile и AddFileTemplate. По ходу генерации кода может быть создано несколько потоков - для деклараций переменных и констант, деклараций и реализаций классов и так далее. После просмотра всей задачи, сформулированной технологом, эти потоки сшиваются в один результирующий поток. Для частных потоков можно использовать строковую реализацию, а для результирующего потока - файловую.
2. Компиляция
После того, как исходный код создан, требуется его откомпилировать. Компилятор dcc32 замечательно подходит для этой роли - он очень быстрый, качественный и объединяет в себе все, что необходимо для построения exe-файлов, dll-библиотек и пакетов. Размер файла dcc32.exe (версия 12.0, из Delphi 5) всего 545 Кб, ранние версии имеют еще меньший размер. К нему нужно добавить только три файла - rlink32.dll, sysinit.dcu и system.dcu (это минимум). Компилятор и указанные файлы можно разместить в подкаталоге прикладной программы, например, bin. Генерировать текст целесообразно в подкаталоге компилятора, например, bin\pas, чтобы использовать короткие пути файлов и не засорять каталог компилятора.
Для вызова dcc32.exe в библиотеке DccUsing определена функция ExecDcc32. Она устанавливает текущий каталог, создает файл для перехвата ошибок компиляции, вызывает компилятор, дожидается завершения компиляции и определяет наличие ошибок.
function ExecDcc32(const aDccDir, aOptions, aProjectPath, aErrorPath: String; aCheckPaths: Boolean = False): Boolean;
Функция принимает аргументы: aDccDir - каталог, в котором находится компилятор Dcc32, aOptions - опции компилятора (рекомендации по их использованию смотрите в файле DccUsing.pas), aProjectPath - путь файла проекта (обычно dpr), aErrorPath - путь файла, куда будут направляться сообщения об ошибках компиляции. Необязательный аргумент aCheckPaths позволяет разрешить или запретить контроль наличия каталога и файла dcc32.exe. Функция возвращает True, если компиляция была успешной и False в противном случае. Предупреждения (hints и warnings) ошибками не считаются - их выводом можно управлять с помощью опций -H и -W. Опуская детали, рассмотрим немного подробнее эту функцию:
// сохранение текущего каталога и установка нового CurDir := GetCurrentDir; if not SetCurrentDir(DccDir) then raise Exception.Create(SCantChangeDir + DccDir); try hStdOut := INVALID_HANDLE_VALUE; try // установки атрибутов безопасности with SecurAtt do begin nLength := SizeOf(SecurAtt); lpSecurityDescriptor := nil; // разрешить наследование дочернему процессу bInheritHandle := BOOL(True); end; // создание файла, в который будут направляться ошибки hStdOut := CreateFile(PChar(aErrorPath), GENERIC_WRITE, 0, @SecurAtt, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0); if hStdOut = INVALID_HANDLE_VALUE then raise Exception.Create(SCantCreateFile + aErrorPath); // заполнение структуры, специфицирующей создание процесса ZeroMemory(@StartupInfo, SizeOf(StartupInfo)); with StartupInfo do begin cb := SizeOf(StartupInfo); // скрывать окно компилятора и наследовать потоки ввода-вывода dwFlags := STARTF_USESHOWWINDOW or STARTF_USESTDHANDLES; wShowWindow := SW_HIDE; hStdOutput := hStdOut; end; // создать и стартовать процесс компилятора s := 'dcc32.exe ' + aOptions + ' ' + aProjectPath; if not CreateProcess('dcc32.exe', PChar(s), @SecurAtt, @SecurAtt, BOOL(True), 0, nil, PChar(DccDir), StartupInfo, ProcessInfo) then raise Exception.Create(SCantCreateProcess + 'dcc32.exe'); // ждать завершение компиляции неопределенное время WaitForSingleObject(ProcessInfo.hProcess, INFINITE); // получить результат компиляции ResultCode := 0; GetExitCodeProcess(ProcessInfo.hProcess, ResultCode); result := ResultCode = 0; finally // закрыть файл ошибок if hStdOut <> INVALID_HANDLE_VALUE then CloseHandle(hStdOut); end; finally // восстановить прежний каталог по умолчанию SetCurrentDir(CurDir); end;
Установка каталога компилятора, как текущего, позволяет не заботиться о мелочах, связанных с назначением путей. Компилятор направляет сообщения об ошибках в стандартный файл вывода. Для его перехвата создаем свой файл, дескриптор которого передаем компилятору. Для того, чтобы процесс компилятора мог наследовать дескриптор открытого файла, устанавливаем его атрибут наследования. При заполнении структуры StartupInfo указываем, что окно компилятора должно быть скрытым и порождаемый процесс должен наследовать стандартные потоки ввода-вывода. Атрибуты безопасности, передаваемые функции создания процесса, нужны для правильной работы в NT, в Windows 95-98 их можно было бы опустить. Функция CreateProcess сохраняет параметры процесса в структуре ProcessInfo - мы используем дескриптор процесса, чтобы передать его функции ожидания системного события - в данном случае, завершения процесса. С помощью GetExitCodeProcess получаем значение, которое возвращает компилятор. Если компиляция была успешной, то возвращается 0, иначе - ненулевое значение. Операции закрытия файла ошибок и восстановления предыдущего каталога произойдут независимо от возможных исключительных ситуаций по ходу функции ExecDcc32.
Компилятору, вместе с исходным файлом (файлами), нужно также передать файл проекта (dpr) и уточнить в опциях, что же будет результатом компиляции. Возможных вариантов много - GUI или консольное приложение, dll, пакет, ActiveX (наверное, есть еще варианты). Выбор вида компиляции связан со спецификой задачи, требованиями пользователя и вкусами разработчика. К этому вопросу я еще раз вернусь в разделе Исполнение кода.
3. Диагностика ошибок
Идеальный вариант - это генерация синтаксически и семантически правильного кода. Но проверка семантики в большинстве случаев вряд ли возможна, поэтому желательно генерировать, по крайней мере, синтаксически правильный код. В этом случае компиляция всегда будет успешной. Если проверка синтаксической корректности затруднительна или невозможна, то приходится полагаться на диагностику, которую сформирует компилятор. Конечно, давать эту диагностику технологу - это самый последний случай, когда уже ничего не остается. Более спокойный вариант - это извлечь из файла ошибок номера ошибочных строк и определить, чему они соответствуют в том описании, который сделал технолог. Для разбора файла ошибок, библиотека DccUsing содержит класс TParseDcc32Errors. Класс весьма прост, поэтому я только обрисую его интерфейс:
TCompileMessageStatus = (cmsNone, cmsHint, cmsWarning, cmsError, cmsFatal); public procedure ParseFile(const aFileName: String); function MessagesCount: Integer; function StatusCount(aStatus: TCompileMessageStatus): Integer; function MessageText(aIndex: Integer): String; function MessageStatus(aIndex: Integer): TCompileMessageStatus; function MessageFile(aIndex: Integer): String; function MessageLine(aIndex: Integer): Integer;
TCompileMessageStatus перечисляет все возможные статусы ошибок. Процедура ParseFile выполняет разбор файла ошибок и сохраняет результат в своем приватном списке. Функция MessagesCount возвращает общее количество сообщений, а StatusCount - количество сообщений с заданным статусом. Оставшиеся 4 функции разбирают строку сообщения компилятора на составляющие - текст сообщения, статус, имя файла, в котором обнаружена ошибка и номер строки.
Вот теперь можно вернуться к необъясненным методам TCompileOut. Метод AddPoint добавляет в поток контрольную точку. Контрольная точка - это просто целое число, которое помечает уникальным номером начало некоторой части генерируемого кода и жестко связывается с номером строки. Контрольная точка может служить, например, индексом в таблице ошибок. Расставив при генерации кода такие точки-метки, мы можем локализовать место ошибки. Для поиска ошибки нужно повторить генерацию кода без вызова компилятора (чтобы опять сформировать выходной поток), а затем, для результирующего выходного потока, вызвать функцию FindPoint, передав ей номер ошибочной строки. Эта функция определит ближайшую точку ошибки. Если генерируется несколько файлов исходных кодов, то выбор ошибочного файла сделать с помощью функции, возвращающей имя файла - MessageFile.
4. Исполнение кода
Как я уже говорил, возможны различные варианты того, в какой вид будет скомпилирована задача, сформулированная технологом. Если технолог передает результаты своей работы конечному пользователю, то удобный вариант - exe-файл. Если технолог решает некоторую задачу и сразу же пользуется результатами решения, то сам факт компиляции должен быть для него полностью прозрачен (или максимально незаметен). Технолог работает в программе, которая сделана разработчиком и, по большому счету, ему совершенно безразлично, каким конкретно способом разработчик предоставляет возможность изменять функциональность программы. Существует несколько технологий построения гибко подгружаемых модулей, и они описаны в литературе. Я остановлюсь только на одной технологии - динамическая загрузка и выгрузка DLL. Если результирующий проект, который нужен технологу, содержит визуальные формы (а их можно генерировать как dfm-файлы), то вероятно, более предпочтительными будут пакеты.
Возможен также вариант, когда исполняемый код делится на две составляющие - исполняемое ядро (exe-файл) и подгружаемый модуль. Ядро создается разработчиком и динамически подключает модули, создаваемые технологом. Достоинство такого подхода в том, что работу с визуальными компонентами можно сосредоточить в ядре, а в DLL формировать только алгоритмическую часть задачи. Другое достоинство такого подхода - технолог может работать в мощной интегрированной среде, а конечному пользователю он передает только ядро и нужный модуль, скрывая от пользователя все технологические детали.
Для работы с DLL, в библиотеку DccUsing добавлен класс TDllWrap - простая оболочка, инкапсулирующая дескриптор загруженной DLL. Основные методы класса:
public constructor Create(const aDllPath: String); destructor Destroy; override; function Execute(const aFunctionName: String; const aInterface: Pointer): Pointer;
Конструктор Create просто сохраняет путь к файлу DLL и больше ничего не делает, деструктор Destroy выгружает DLL из памяти, если она была загружена. Основную работу делает метод Execute - он вызывает экспортируемую функцию DLL по имени и передает ей указатель на интерфейс вызывающей части. Экспортируемая функция возвращает интерфейс вызываемой части. Более подробно о взаимодействии вызывающей и вызываемой частей поговорим в следующем разделе, а пока рассмотрим реализацию метода Execute.
function TDllWrap.Execute(const aFunctionName: String; const aInterface: Pointer): Pointer; var f: TDllFunction; begin if FDllInst = 0 then begin if not FileExists(FDllPath) then raise Exception.Create(SFileNotFound + FDllPath); FDllInst := LoadLibrary(PChar(FDllPath)); if FDllInst = 0 then raise Exception.Create(SCantLoadDll + SysErrorMessage(GetLastError)); end; f := TDllFunction(GetProcAddress(FDllInst, PChar(aFunctionName))); if not Assigned(f) then raise Exception.Create(SCantFindFunction + aFunctionName); result := f(aInterface); end;
Вначале метод Execute контролирует - загружена ли DLL? и, если DLL еще не загружена, то она загружается. Если загрузка была успешной, то с помощью функции GetProcAddress получаем адрес экспортируемой функции по ее символическому имени (можно также использовать индекс). Если адрес функции успешно получен, то вызываем ее и передаем ей аргумент - указатель на вызывающий интерфейс. Функция возвращает указатель на вызываемый интерфейс. Из этой реализации видно, что вызывающая часть может обратиться с помощью метода Execute к нескольким различным функциям DLL или многократно к одной и той же функции - DLL будет загружена только один раз.
5. Взаимодействие с DLL
С самых общих позиций можно считать, что вызывающая (Master) и вызываемая (Slave) части обладают своими интерфейсами. Экспортируемая функция конструирует Slave-интерфейс и возвращает его. Экспортируемая функция играет в этом случае роль фабрики класса. Сигнатура экспортируемой функции выглядит так:
TDllFunction = function(aInterface: Pointer): Pointer; StdCall;
После вызова этой функции Master и Slave части взаимодействуют друг с другом через свои интерфейсы. В качестве интерфейса наиболее удобно использовать чистый абстрактный класс, например:
IMaster = class public procedure Method1; virtual; abstract; ............. end;
Виртуальный абстрактный класс не содержит переменных, а все его методы - виртуальные и абстрактные. Декларация интерфейса включается в обе взаимодействующие части. Для реализации интерфейса создается класс, наследуемый от абстрактного интерфейса и переписывающий все его виртуальные методы. Интерфейсный объект Master-части конструируется и удаляется в основной программе. Интерфейсный объект Slave-части конструируется в экспортируемой функции DLL, а уничтожается в блоке finalization при выгрузке DLL или с помощью другой экспортируемой функции. Например:
uses UnitIMaster, UnitISlave; type TSlaveObject = class(ISlave) private FMain: IMain; public constructor Create(aMain: IMain); destructor Destroy; override; procedure Method1; override; ............ end; function CreateSlave(aInterface: Pointer): Pointer; stdcall; function DestroySlave(aInterface: Pointer): Pointer; stdcall; implementation var SlaveObject: TSlaveObject; // Реализация TSlaveObject ............ function CreateSlave(aInterface: Pointer): Pointer; begin SlaveObject := TSlaveObject.Create(IMaster(aInterface)); result := SlaveObject; end; function DestroySlave(aInterface: Pointer): Pointer; begin SlaveObject.Free; SlaveObject := nil; result := nil; end; initialization SlaveObject := nil; finalization SlaveObject.Free; end.
Пример реализации
Эффект использования описанной технологии повышается при увеличении сложности программы, для простых программ она вряд ли целесообразна. В качестве примера я расскажу об одной из своих разработок: Visual2k - Интегрированная среда для программирования микроконтроллерных кукол-роботов. Подробнее о ней и о других программах, использующих "многозвенное программирование" можно узнать на моем web-сайте.
Программа Visual2k разработана для томского театра кукол "2+Ку" и проекта "Оживление пространства". Суть проекта состоит в создании кукол-роботов, используемых в рекламных целях, в витринах магазинов и кафе, в качестве гидов на выставках. Куклы могут быть одиночными или работать совместно в автоматическом спектакле. Разработка каждого нового проекта включает в себя такие фазы - художник и сценарист по заказу придумывают сценарий спектакля, затем, вместе с конструкторами обсуждают детали реализации. Когда детали проекта уточнились, инженер-электронщик изготавливает микроконтроллерную аппаратуру, инженер-механик конструирует механику кукол и приводы двигателей, а режиссер - создает с готовыми куклами спектакль. То есть, здесь мы имеем целую цепочку технологов, каждый из которых работает со своей предметной областью.
Visual2k содержит подсистемы, которые позволяют всем технологам работать над проектом в одной и той же интегрированной среде. Задача первого технолога (электронщика) - не только сконструировать аппаратуру, но и начать создание базы проекта. Вот так выглядит подсистема, в которой он работает:
Редакторактеров
Электронщик вносит в базу проекта имена и типы микроконтроллеров, а также назначает привязку приводов к конкретным выводам микроконтроллеров. Этой информации достаточно, чтобы автоматически сгенерировать программу для микроконтроллеров. Исходный код программы для микроконтроллеров генерируется на языке Си с использованием заранее заготовленной библиотеки драйверов и компилируется Си-компилятором. В зависимости от типа микроконтроллера, генерируется тот или иной Си код.
После этого, файл проекта поступает ко второму технологу (механику). Он работает в этой же подсистеме и создает актеров или их части, назначает действия, которые должны делать куклы-актеры и учитывает специфику приводов, например, создает таблицу скоростей двигателей.
Полученный файл проекта вместе с готовой механикой и электроникой поступает к третьему технологу - режиссеру. Он работает уже с другой предметной областью - сценарием. Поскольку сценарии бывают очень сложными, то режиссер программирует движения кукол на простом алгоритмическом языке, который включает в себя понятия параллельных и последовательных веточек, циклов, условий и команд управления куклами. Вот как выглядит подсистема, в которой работает режиссер:
Редакторсценария
Здесь стоит отметить, что Visual2k немного напоминает Delphi, с той разницей, что здесь все представлено визуальными компонентами, даже переменные и операторы. Режиссер выстраивает сценарий, выкладывая на рабочую область компоненты-операторы, и назначает их свойства с помощью инспектора объектов. Программа сценария, полученная таким образом, может выполняться либо на персональном компьютере (для сложных говорящих кукол или спектаклей), либо на одном микроконтроллере или сети микроконтроллеров. Исходный вид сценария один и тот же, но генерируется либо текст на языке Object Pascal, который компилируется dcc32, либо текст на языке Cи, объединяемый с частью, заданной электронщиком и компилируемый Cи-компилятором. Поскольку в операторах присваивания и в условиях могут быть выражения, Visual2k включает в себя синтаксический анализатор выражений, обнаруживающий все синтаксические ошибки и заменяющий русские имена переменных и функций на имена, допустимые для языка Pascal. Если кукольный проект будет выполняться под управлением персонального компьютера, то сценарий компилируется в DLL, которая вызывается исполняющим ядром. Такая структура позволяет передавать заказчику только исполняющее ядро и DLL, не раскрывая фирменных секретов спектакля. Параллельные процессы, необходимые для адекватного описания сценария реализуются на базе библиотеки параллельного программирования Gala. Если же сценарий выполняется в микроконтроллере, то используется специально разработанная многозадачная среда с кооперативной мультизадачностью и сценарий зашивается прямо в ПЗУ.
Когда мы получили первый заказ на кукольный спектакль, программы Visual2k еще не было, и я писал сценарий самостоятельно (на Delphi) - режиссер сидел рядом ничего не понимая в том, что я делаю, и только давал советы. Я плохо понимал, чего хочет режиссер, а режиссер вообще не понимал, что я делаю. После создания Visual2k, я занимался только развитием интегрированной среды, добавлял поддержку новых типов микроконтроллеров и новых типов приводов, совершенно не вникая в то, какие делались спектакли. Режиссер очень быстро освоил простой язык описания сценариев и получил полную свободу в реализации своих режиссерских замыслов. Так мне удалось расправиться с целым стадом зайцев - существенно облегчить себе задачу сопровождения программы, освободиться от написания конкретных сценариев, освободить электронщика от написания программ для микроконтроллера и высвободить себе время для других разработок.
The end.
Сергей Гуринhttp://www.tomsk.net/2q/gurin/index.html
Специально для Королевства Delphi