понедельник, 28 июня 2010 г.

§ 5. О вызовах DLL из LabVIEW

LabVIEW — довольно высокоуровневая среда разработки. Работая с LabVIEW, мы не задумываемся о колоссальной работе компилятора, остающейся "за кадром". Мы просто соединяем  элементы друг с другом, совершенно не заботясь о том, как резервируются области памяти, как предаются параметры, что происходит в регистрах, и так далее. Кто сегодня может сказать, сколько регистров у процессора? Что происходит со стеком, как располагаются структуры в памяти? Однако рано или поздно любого программиста настигают ситуации, когда подобное знание необходимо. Одна из таких ситуаций — вызов DLL из кода LabVIEW. Когда требуется подключать сторонние DLL? Существует несколько типичных ситуаций:
- производитель какого-либо устройства предоставил вам DLL для коммуникации
- требуется использовать WinAPI, например для работы с окнами
- ускорение критичных участков (LabVIEW довольно медленна сама по себе, как правило переписывание части кода на Си позволяет ускорить выполнение в несколько раз)

Для упражнений нам помимо LabVIEW понадобится какой - либо компилятор, позволяющий скомпилировать код в DLL. Я пользуюсь компилятором CVI, также подойдёт VisualStudio (бесплатная Express в том числе). Я не буду описывать процесс создания DLL и пошаговое руководство по подключению DLL к LabVIEW, об этом вы можете прочитать на сайте NI, например в статье Using Existing C Code or a DLL in LabVIEW.

Итак, начнём. Прежде всего надо заметить, что существуют два типа связи сторонних DLL с LabVIEW - статическая линковка и динамический вызов. В первом случае вы явно указываете имя и путь DLL в настройках. Во втором случае вы указываете путь к DLL на блок-диаграмме:



В основном используется статическая связь. Однако вы не сможете запустить VI, если требуемая библиотека будет отсутствовать. В подобных случаях можно подгружать библиотеки динамически, при этом на загрузку библиотеки и поиск соответствующих функций потребуется некоторое время — вот маленькое исследование на эту тему Call Library Function Node Calling Optimization.

 А вот с чем имеет смысл досконально разобраться, так это с соглашениями о вызовах. Вы, вероятно уже заметили, что при подключении внешнего кода требуется установить флажок "Calling conventions":



А чем, собственно, отличаются "stdcall (WINAPI)" и "С"? Существует несколько соглашений о вызовах (stdcall, cdecl, thiscall, fastcall). Нас сейчас интересуют первые два (thiscall используется в С++, а fastcall — довольно редко). Наиболее часто используется cdecl (этому соглашению соответствует опция C), а stdcall в основном используется фирмой Микрософт как основное соглашение для вызовов WinAPI. Как правило по умолчанию в компиляторах включён режим cdecl.

Давайте сделаем небольшую тестовую DLL, в которой исследуем то, каким образом передаются и возвращаются параметры, а также изучим различные соглашения о вызовах. Мы можем смело скомбинировать оба соглашения в рамках одной DLL, для этого напишем следующий код:

Параметры в DLL можно передавать по значению или по ссылке. В вышеприведённом примере параметр In передаётся по значению, а параметр Out передаётся по ссылке (то есть передаётся не значение переменной, а адрес, по которому она находится). Подключение библиотеки выполняется следующим образом:

Обратите внимание на строку, где показывается прототип вызываемой функции — используйте эту строку для контроля правильности описания параметров:

Что же происходит "за кадром"? Давайте заглянем внутрь наших функций:

Вот как выглядит функция, вызываемая как stdcall:


А вот как выглядит функция, декларированная как cdecl:


Итак, как мы видим, происходит следующее:
Параметры передаются через стек. Регистр esp - это и есть указатель на стек. Через стек передаются два аргумента. Параметр In смещён на четыре байта и его значение переносится из стека в регистр eax. Использование именно eax не случайно - это именно тот регистр, через который осуществляется возврат значения функцией (return In). Второй параметр Out смещён на восемь байт и его значение (при этом мы помним, что передали адрес) заносится в регистр edx. Третьей командой мы записываем значение eax по адресу, находящемуся в edx, затем возвращаемся в исходную программу. Вот здесь мы и видим отличие двух соглашений о вызовах. При вызове cdecl параметры заносятся в стек в обратном порядке, при этом указатель стека не изменяется (кстати, это даёт возможность вызывать функции с переменным количеством параметров, но не в LabVIEW). При вызове stdcall параметры передаются в прямом порядке, так как они объявлены в декларации, при этом указатель стека смещается на необходимое количество байт. В случае stdcall команда retn 8 выполняет коррекцию стека на 8 байт при выходе, а при использовании cdecl коррекция стека в теле функции не требуется. Что произойдёт в случае, если мы перепутаем соглашения? Мы получим неверный указатель стека при завешении функции, что в любом случае приведёт к проблемам. Дело даже не в возможной утечке памяти в стеке, а ещё и в том, что в стеке находятся другие данные, и неверный указатель практически моментально (или через некоторое время) приведёт к падению программы. Об этом также можно почитать в разделе помощи: Configuring the Call Library Function Node. И вот здесь мы натыкаемся на прелюбопытнейший эффект: программа продолжает работать как ни в чём не бывало даже в том случае, если мы неверно указали тип вызова! Оказывается, LabVIEW автоматически корректирует указатель стека даже в случае нашей ошибки. После нескольких экспериментов легко выяснить, что это происходит при установке флага Error Checking Level в Default (именно это значение и стоит по умолчанию):


Таким образом мы видим, что по всей вероятности LabVIEW сохраняет значение указателя стека перед вызовом функции, а затем проверяет значение после выхода из функции и выполняет автоматическую коррекцию в случае, если это необходимо (поскольку прототип вызываемой функции известен, а, следовательно известно и ожидаемое значение указателя стека после вызова). Заметьте, что никаких сообщений об ошибках при этом не выводится.

Что произойдет в случае выбора других опций? Это легко выяснить:
Disabled - приводит к немедленному падению LabVIEW, что и следовало ожидать
Maximum - приводит к ошибке 1517:


Сообщение об ошибке в случае неверного типа функции  документировано, об этом можно почитать в статье Call Library Function Dialog Box. Таким образом следует обращать внимание на правильность указания соглашения о вызове. В случае неверного указания типа ошибку можно заметить не сразу, а например при изменении опции контроля ошибок.

Ещё один вопрос, на который мы можем ответить — что произойдёт в том случае, если мы укажем, например, то, что фукнция возвращает значение, хотя на самом деле тип функции void, либо наоборот. В данном случае фатальных последствий не будет, так как возвращаемое значение просто записывается в регистр eax, так что в одном случае мы просто получим значение этого регистра, а в другом случае просто проигнорируем его. Утечек памяти и исключений не возникнет (пытливый читатель может разобраться с тем, что происходит при возврате восьмибайтовых типов или строки самостоятельно).

Ещё одна из самых типичных ошибок — это неверное указание типа передаваемых параметров. В вышеприведённом примере наша функция имеет два параметра. Один из них (тот, который Out) передаётся по ссылке, и мы должны указать, что передаётся адрес переменной. Давайте намеренно совершим ошибку и передадим нулевое значение вместо адреса:

Запись по нулевому адресу немедленно вызывает исключение, а вот внешнее проявление опять-таки зависит от опции Error Checking. В случае Maximum или Default результатом будет ошибка 1097:


В случае, если мы выключим обработку ошибок вообще, то LabVIEW "упадёт". Таким образом, включение обработчика ошибок приводит также и к включению внутреннего обработчика исключений (примерно также, как это делается в C++ секциями try... catch).

Побаловавшись с простыми типами, можно перейти к массивам. В простейшем случае мы просто передаём в функцию указатель на массив. Давайте попробуем сделать простое поэлементное копирование массивов:

Здесь важно понимать то, что функция понятия не имеет о размере передаваемых массивов, так что количество копируемых элементов надо явно указать как параметр и передавать извне. Кроме того надо обратить внимание на то, что память под массив надо резервировать снаружи, в LabVIEW:


Типичная ошибка, которую можно совершить — выход за пределы массива. Как правило это приводит к исключению, либо нарушению работоспособности программы. Здесь снова может помочь включение режима отлова ошибок на максимум - в этом случае LabVIEW сообщить вам то, что вы выскочили за пределы массива (впрочем это не всегда спасает её от падения). Возникает логичный вопрос — а нельзя ли передать одновременно с массивом и количество элементов, а также зарезервировать память внутри DLL? Это сделать можно, но перед рассмотрением этого вопроса имеет смысл поэкспериментировать с простыми структурами, так как у LabVIEW есть одна небольшая тонкость.

Сделаем следующий пример:

Структурам в Си отвечает кластер в LabVIEW, но типичнейшая ошибка, которую как правило совершает начинающий программист, выглядит примерно вот так:




В лучшем случае результат далёк от ожидаемого, а в худшем код вызывает исключение.
Как же правильно передать структуру (читай - кластер) в DLL и обратно? Для этого нужно разобраться с тем, как хранятся данные в кластерах LabVIEW и в структурах Си. Основной документ, с которого имеет смысл начать — How LabVIEW Stores Data in Memory.

При передаче кластера в DLL мы фактически передаём указатель на адрес памяти, в котором хранится наш кластер. DLL принимает этот адрес и обращается к элементам структуры. Очевидно то, что реальное размешение кластера в памяти должно с точностью до байта отвечать ожидаемому расположению элементов в структуре.

Прежде всего надо заметить, что массивы, строки и пути не хранятся непосредственно в кластере LabVIEW. Вместо них хранятся адреса (причём не на непосредственно данные, а на заголовки). Таким образом в приведённом примере передача массива или строки в составе кластера смысла не имеет. Поскольку массив в нашем примере хранится непосредственно в структуре, то и передавать его надо поэлементно. Здесь нас подстерегает ещё одна опасность. Каков размер структуры в Си (тот, что возвращает sizeof(TD_MyStruct))? Если вы думаете, что 13 байт (int + char + 2 x int = 4 + 1 + 2 x 4), то вы ошибётесь. Дело в том, что между членами char и int "вставлено" три дополнительных байта, которых вы не видите. Это сделано для исключения пенальти при обращении по адресам, невыровненным на границу 32х бит. Примеры выравнивания в структурах можно посмотреть в msdn: Structure Alignment Examples. По умолчанию установлено выравнивание на границу 4 байт, так что истинный размер структуры в нашем примере не 13, а 16 байт. А вот в LabVIEW элементы кластеров хранятся без выравнивания. Чтобы решить проблему выравнивания, существуют два пути - либо вставить недостающие "padding" элементы в кластер (это может быть использовано в том случае, если DLL не может быть перекомпилирована):



либо явно указать компилятору что наша структура не требует выравнивания (это достигается при помощи #pragma pack(1) непосредственно перед объявлением структуры). Обратите внимание, что #pragma pack(1) будет распространяться на все структуры до момента пока не встретится следующая #pragma pack.

В заключение - маленький совет. Поскольку порядок следования элементов в кластере весьма важен, то для того, чтобы не проверять каждый раз порядок через меню "Reorder Elements in Cluster" просто установите опцию Arrange Vertically (доступно в контекстном меню) — в этом случае расположение элементов в кластере всегда будет отражать реальный порядок следования.

Вот собственно и всё, на что стоит обратить внимание при подключении стороннего кода из DLL в LabVIEW:
- соглашение о вызовах (stdcall или cdecl)
- передача параметров по ссылке или по значению
- выравнивание в структурах и порядок следования элементов.

За кадром пока что остались опции управления потоками исполнения (UI или любой поток), функции управления менеджером памяти LabVIEW из стороннего кода и функции обратного вызова при определённых событиях, но об этом как-нибудь в другой раз...

Комментариев нет:

Отправить комментарий