Windows Dynamic Link Libraries Programming

В одном из предыдущих номеров КГ мною была в общих чертах описана основополагающая в Windows технология DLL (Dynamic Link Library). Признаться честно, уж очень тема показалась мне интересной и хорошо объяснимой рядовому юзеру (а таковых в читающей уважаемую КГ братии отнюдь не меньшинство). Кроме того, предмет, снова предлагаемый мною к обсуждению (жаль, одностороннему), дает возможность привести реальные, жизненные примеры, а не "кормить" читателя абстрактными разглагольствованиями о существовании таковых и витиеватыми предложениями вроде этого. Вот я и решил в этот раз попусту не тратить место, отведенное в газете под статью, и без лишних лирических отступлений заняться практикой. Конкретно предлагаю (чистейшей воды диктат) полностью "разжеванный" пример написания динамической библиотеки на Delphi, с последующей удачной(!) попыткой вызова ее из программы, написанной на Visual Basic.

Любители почитать хелпы Delphi в качестве примера на эту тему смогут найти реализацию в виде DLL функций min и max - выбора из двух чисел соответственно наименьшего и наибольшего. Пусть это есть живой пример написания библиотеки, но ничего кроме холодного трупа, в смысле от него пользы, он из себя не представляет. Поэтому, дабы не тратить силы на высасывание из пальца очередного шедевра, столь же широко используемого, как и всенародно любимая HelloWorld, остановимся на чуть более жизненной вещи, а именно - функциях конвертации десятичной записи числа в двоичную и наоборот (кто-то ехидно улыбнулся, вспомнив школьную информатику). Однако не все так просто - в большинстве доступной литературы приводятся по большей части примеры функций, в качестве параметров принимающие числовые данные и их же возвращающие. Я же хочу остановиться на "текстовых" функциях.

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

Наиболее распространенное определение понятия строки, с точки зрения машины, - это некоторая последовательность байтов (8-bit) (ASCII, ANSI) либо машинных слов (16-bit) (UNICODE), представляющих собой коды символов в соответствующих таблицах. На каком принципе основывать управление такими данными, решать программисту. Способов реализации строковых параметров превеликое множество. Один из них - использование двусвязных или односвязных списков, где каждый элемент списка содержит код символа и ссылку на следующий элемент (в первом случае) и дополнительно - на предыдущий (во втором). Иногда для удобства списки элементов замыкают в кольцо. Работать с такой конструкцией более чем удобно, но основная проблема - высокие требования к объему оперативной памяти. Во многих языках программирования (C и C++ в том числе) вообще отсутствует понятие текстовой переменной. Построенные на них системы (не исключая и Windows) чаще всего используют конструкцию, которая называется null-terminated string. Object Pascal (Delphi) обеспечивает полный набор функций для работы с такого вида строками. Для них выделен специальный тип переменных - PChar, который ничем от стандартного Pointer не отличается и полностью с ним совместим. Грубо говоря, такая "строковая переменная" является всего лишь ссылкой на блок памяти - последовательность байт, оканчивающаяся символом #0. Длина такой строки высчитывается прямым пробегом по символам до нулевого. Поэтому при интенсивной обработке больших объемов текстовой информации такой подход может быть неэффективен. Стандартная, object pascal-евская String (AnsiString), устроена хитрее. Это, опять же, только указатель, но не просто на цепочку символов, а на блок памяти, где помимо последовательности байтов присутствует еще и 32-битный идентификатор длины, а также счетчик использования. Все управление такими строками Delphi берет на себя. Недостаток один - они слабо совместимы с null-terminated strings, что доставляет программеру массу удовольствия при написании под Windows динамической библиотеки, реализующей текстовые функции. Delphi, в принципе, позволяет написать DLL и с использованием "родных" строк, но с условием привязки к вашему приложению еще одной - delphimm.dll - стандартной библиотеки Delphi для управления памятью. Такой вариант влечет увеличение совокупного размера программы, потерю ее целостности и, что немаловажно, для полного контроля над приложением со стороны программиста, прозрачности. Однако в этом случае, в результате перехода на более высокий уровень программирования, упрощается разработка в целом. Мое чисто субъективное мнение по этому поводу выражается следующим образом: программа должна быть прозрачной и как можно более независимой. Поэтому для написания толковой стабильной DLL я и выбрал тернистый путь работы с null-terminated strings.

Ясное дело, что у меня нет права отбирать кусок хлеба у help-райтеров Enprise/Borland, так что заниматься описанием структуры исходного кода библиотеки на Delphi я не буду - она и так весьма прозрачно приведена в примерах и практически ничем не отличается от структуры исходников любого из unit-ов. Более интересно (и важно!) было бы разобраться с описанием способов передачи параметров процедурам (в оригинале - calling conventions) в Delphi (большинство из них являются стандартными для других языков программирования). Calling convention указывается при объявлении процедуры или функции в виде директивы (как директивы assembler, forward, external и т.д.) и устанавливает порядок передачи параметров через стек, характер использования при этом регистров процессора, способ удаления параметров из стека после окончания работы подпрограммы. Характеристики основных calling conventions приведу в таблице.

Register является наиболее предпочтительным соглашением, так как в большинстве случаев (при малом количестве параметров) избегает создания фрейма стека, что при интенсивной работе с подпрограммами значительно ускоряет работу, существенно экономит память при глубоких рекурсиях. По register calling convention первые три параметра пересылаются через регистры EAX, EDX, ECX (именно в таком порядке), а остальные - через стек. Все данные передаются слева направо. Т.е. параметр, объявленный первым, будет сидеть в EAX, а последний, если их больше трех, - на вершине стека. Это соглашение устанавливается Delphi по умолчанию, если явно не прописано никакой директивы. Однако в нашем случае такой номер не пройдет. Дело в том, что функции Windows API ничего кроме stdcall (реже - safecall) в качестве calling convention не признают. Ничего удивительного, что такое соглашение является единственным возможным вариантом и для Visual Basic. Поэтому при объявлении функций DLL мною и была использована директива stdcall. Передаваться в этом случае параметры будут только через стек, причем в обратном объявлению порядке - то есть самый первый описанный параметр будет лежать на вершине стека. Отчасти из-за использования именно stdcall calling convention, при одинаковом объеме памяти, выделенной под стек, stack overflow в Visual Basic наступает значительно быстрее, чем в Delphi при многочисленных рекурсивных вызовах функций. И еще одно небольшое техническое лирическое отступление по поводу самой передачи параметров. Первое, что хотелось бы заметить, так это то, что любые два параметра, передаваемые через стек, занимают ровненько по 4 байта - т.е. будь они хоть байтами, хоть словами, для передачи их понадобится 8 байт - два двойных слова. Содержимое неиспользованной части двойного слова в первых двух случаях не определено.

Ясное дело, ordinal (byte, integer, longint, word, dword)-данные пересылаются через стек как есть. То же самое касается и чисел с плавающей точкой. Тут, как и в случае с пересылкой целочисленных данных, происходит "расширение" - размер пересылаемого параметра приводится к кратному длине двойного слова (к примеру, Extended-число занимает 12 байт, но использует только 10 нижних, точно так же, как и Real48, которое размещается в 8-ми, а на самом деле требует только 6 байт). Вещественные данные передаются исключительно через стек под всеми стандартными calling conventions. Вместо всего остального (объекты, строки, массивы и т.д.), за некоторыми исключениями, процедура получает лишь 32-битный указатель на данные конструкции.

В тему к указателям посмотрим изнутри на отличие между параметрами by value (по умолчанию, если не прописана директива var) и var-параметрами (соответствующее указание на месте). Для удобства первых назовем value parameters, а вторых - variable parameters. Value parameters ведут себя абсолютно точно так же, как и локальные переменные процедуры, которым просто присвоено значение передаваемого параметра. Т.е. в стеке (говорим о stdcall) размещается попросту копия пересылаемых данных (либо ссылки на них). Интересной становится ситуация при попытке организовать в процедуре деструктивные действия к примеру над строками (конкатенацию, хотя бы). В этом случае работа идет со ссылкой на блок памяти, где сама строка, собственно, размещается. Но! Так как изменяться исходные данные не должны, а работать с чем-то надо, то происходит создание абсолютно новой строки соответствующего размера и полное побайтовое ее копирование. Таким образом, если у вас была строка массой 1 Мб, то при передаче ее by value процедуре деструктивного (изменяющего содержимое) характера в памяти (не в стеке, боже упаси!) появится еще одна такая же! Сложно себе даже представить, что будет, если рекурсивно вызывать процедуру с таким параметром. Если же процедура работает с текстом "в режиме чтения", ничего подобного не происходит. Конкретно для String (AnsiString) в Delphi производится только инкремент счетчика ее использования.
Другое дело, когда работа идет с variable parameters. В этом случае процедура получает не значение, а, собственно, адрес переменной, что дает возможность делать с ней все, то заблагорассудится. И будь ваша строка хоть размером в гигабайт, дополнительно памяти для передачи ее как variable parameter понадобится только 4 байта.

А теперь попробуем со всем этим взлететь. Итак, Delphi:

Library conv;

procedure IntToBin(n: LongInt; s:PChar); assembler; stdcall;
asm
pushedi
moveax, n
movedx, s
xoredi, edi
@1:clc
shleax, 1
jc@2
movcl, 30h
jmp@3
@2:movcl, 31h
@3:movedx[edi], cl
incedi
cmpedi, 20h
jnz@1
movcl, 00h
movedx[edi], cl
popedi
end;

procedure BinToInt(s: PChar; Var n: LongInt); assembler; stdcall;
asm
moveax, s
xoredx, edx
pushedx
@2:movcl, eax[edx]
cmpcl, 31h
je@3
clc
jmp@1
@3:stc
@1:rcldword ptr [esp], 1
incedx
cmpedx, 20h
jne@2
movedx, n
popdword ptr [edx]
end;
Exports
IntToBin Name 'IntToBin',
BinToInt Name 'BinToInt';
End.

Ни для кого не секрет, что целочисленные данные (впрочем, как и все остальные) представляются в виде последовательности битов. Т.е. любое (в пределах допустимого диапазона, конечно) целое число может быть записано в виде двоичной строки (мы работаем с 32-мя битами - читай - машинными двойными словами). Всегда существует и обратное отображение. Задача процедуры IntToBin - реализовать первый случай. Принцип ее действия состоит в последовательном логическом сдвиге влево пришедшего параметра (n) на одну позицию с проверкой флага переноса, в зависимости от состояния которого в результирующую строку (s) дописывается либо ноль, либо единица. Обратная процедура BinToInt формирует конечный числовой результат с помощью последовательного циклического сдвига влево через перенос, с установкой либо очищением флага переноса в зависимости от значения каждого байта "битовой строки". Кому было неинтересно, уж не обессудьте, по-человечески старался.

Я намеренно не лезу в дебри по поводу программной реализации искомых алгоритмов, т.к. сами процедуры никоим образом нас не должны интересовать ввиду того, что это хоть и не совсем тривиальные, работающие и жизненные, но все же примеры. Остановится следует на передаче им параметров. К моменту вызова IntToBin в стеке по stdcal calling convention будет сидеть двойное слово (наше n) и адрес строки, куда будет записываться двоичная репрезентация. Фрагмент кода (IntToBin):
moveax, n
movedx, s

можно переписать в виде:
moveax, [esp]
movedx, [esp + 04h]

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

На первый взгляд может показаться странным, что мы пытаемся передать первой процедуре строку не как variable, а как value parameter, в то время как над нею производятся действия деструктивного характера (запись последовательности нулей и единиц). На самом деле все правильно, так как вместо строки (как и вместо любых других нестандартных объектов) пересылается только 32-битный указатель на блок памяти, в котором она расположена. Работа организуется именно с этим адресом. Если же передавать его как var parameter, то процедура получит указатель на указатель. Попытка что-либо записать по полученному адресу скорее всего приведет к недопустимой операции, выполненной вашим приложением. Директива var при объявлении целочисленного параметра n в BinToInt имеет смысл, так как для записи значения (результат работы процедуры) нужен именно адрес расположения переменной n, а не копия ее содержимого в стеке, изменение которой никак не повлияет на саму переменную. Не зря я так подробно останавливаюсь на передаче параметров процедурам - без досконального понимания технологии с ними работы ничего, кроме "недопустимых операций", написать в принципе невозможно. Особенно если речь идет о настоящем кроссязыковом приложении.

Далее, если уж дотошно придерживаться правил хорошего тона (в программировании) и заботиться о пользователе (читай - не подмочить репутацию фирмы), то неплохо было бы сделать "защиту от дурака", а точнее обработку исключительных ситуаций (exception handling). В самом деле, практически никогда возможности проверить, что пришло в процедуру сверху, не представляется. Delphi же позволяет обойти возможные ошибки с помощью конструкции try...except как в простом модуле приложения, так и в динамической библиотеке. Для этого от программиста требуется только включить в список uses библиотеки модуль sysutils. В этом случае все run-time errors, моментом убивающие приложение, будут преобразованы в exceptions, с которыми "можно договориться". Очевидное преимущество такого подхода - увеличивающаяся стабильность программы. Но недостатков больше (в нашем случае особенно). Во-первых, существенное замедление работы, так как на обработку конструкции try...except даже в идеальном случае (отсутствие ошибок) уходит куда больше машинного времени, чем, собственно, на исполнение самих алгоритмов. Во-вторых, с использованием дополнительного модуля (sysutils) увеличивается размер библиотеки. В-третьих, мое собственное (на этот раз объективное) мнение - если вешать такие заплатки на каждом уровне, то в конце концов получится этакий многослойный пирог, мало похожий на прозрачную и продуманную программу. Защита должна быть одна - на самом верхнем уровне, что далее по тексту и организовано при вызове функций conv.dll из Visual Basic. Принося в жертву скорости, простоте и прозрачности программы ее надежность, я намеренно ничего подобного не реализовываю при конструировании библиотеки.

Как и в простом модуле Delphi, при написании динамической библиотеки целесообразно использовать initialization и finalization секции. Если ваша DLL должна проинициализировать свои внутренние переменные, зарезервировать память, создать объекты, попутно подправив реестр Windows, не найти и вывести сообщение об отсутствии необходимых компонент или файлов прямо в процессе загрузки, то лучше всего организовать необходимые операторы в секции initialization библиотеки. Здесь же (для разумеющих) можно установить процедуру или даже целую цепочку процедур выхода (exitproc (см. Delphi help)), если хотите быть уверенным в том, что какие-либо действия были выполнены в процессе выгрузки DLL из памяти (к примеру, освобождение зарезервированной памяти, удаление объектов и т.д.). Следует, однако, помнить, что операторы finalization-блока все равно выполняются позже любой из exitprocs, так что по-моему лучше не плодить лишний код, когда этого не требуется (в некоторых случаях прямая работа с exitproc просто необходима), и использовать то, что предлагают разработчики Delphi. Еще один совет по написанию и отладке именно библиотеки - не стоит сразу бросаться в меню New...|DLL. Все функции и процедуры гораздо легче будет отладить сначала просто в специально для этого созданном приложении, а потом скопировать и откомпилировать их уже в саму библиотеку. Иначе, для отладки вам понадобится host application (приложение, вызывающее вашу библиотеку), что ни к чему хорошему, кроме кучи забот, не приведет. Если уж вообще невтерпеж, то можно в крайнем случае воспользоваться такой фичей, как project group в Delphi 4.0. и выше.

Александр Муравский БГУ ММФ "Компьютерная математика"
Окончание следует


Компьютерная газета. Статья была опубликована в номере 13 за 2000 год в рубрике программирование :: разное

©1997-2024 Компьютерная газета