на домашнюю страницу автора

к списку статей

Языковой барьер

опубликовано в журнале
«Программист», N1, 2001г.

1. С/C++, Fortran, Visual Basic, и другие.

Недавно ко мне обратился за советом мой знакомый. Ему потребовалось научиться вызывать из Visual Basic функции некой DLL, написанной на C. VB предоставляет такую возможность, однако на практике всё оказалось не так просто. Надо учитывать множество тонкостей, большинство из которых относятся даже не конкретно к VB, но вообще к проблеме организации взаимодействия программ, написанных на разных языках. Сам я впервые столкнулся с этой проблемой довольно давно, когда мне потребовалось использовать модули и библиотеки, написанные на Fortran (Свою программу я писал на C++). Задача организации взаимодействия модулей, написанных с использованием различных языков программирования, возникает не так уж редко. Поэтому я решил собрать воедино данные из различных источников и собственный опыт.

Я начну с того, что расскажу «теоретические основы», как вызов функции выглядит на низком уровне. Обычно, от человека программирующего на языке высокого уровня скрыта эта кухня, однако при необходимости согласовать работу разнородных систем, она сразу вылезает на поверхность, и понимать её необходимо. Во-вторых, намечу основные направления практического решения проблемы применительно конкретно к четырём языкам, с которыми мне приходилось иметь дело: С, C++, Visual Basic, а также Fortran. Последний уже практически ушёл в прошлое, но к сожалению, с ним ещё периодически приходится сталкиваться в научных и инженерных расчётах. К тому же он служит неплохой иллюстрацией общих принципов, которые одинаковы для большинства языков и систем. От языка к языку меняется в основном терминология и обозначения операторов. Поэтому данная статья пригодится и тем, кто работает с другими системами.

Особые замечания. Во-первых, язык C++. Изложенный ниже материал относится главным образом к «чистому C». Особенности C++ оговорены отдельно. Лично я использую Microsoft Visual C++, однако практически всё нижеизложенное входит в стандарт языка и относится в равной степени ко всем компиляторам: MS Visual, Borland, Watcom, Intel, GNUсному и прочим С. Увы, этого нельзя сказать про Fortran, реализации которого могут отличаться друг от друга достаточно сильно. Далее, всё нижеизложенное никак не связано с тем, каким образом, статически или динамически, собраны модули.

И последнее замечание. Сейчас у нас появился новый и очень мощный метод организации взаимодействия программных модулей: COM (Component Object Model). Важнейшее его достоинство — объектная ориентированность. Настоятельно рекомендую изучить его, особенно если вы планируете сложный проект. Вопреки распространённому заблуждению, COM — очень лёгкое и эффективное средство, в отличие от основанного на нём OLE. Сложность и громоздкость последнего и вводит в заблуждение. К сожалению COM — тема для отдельной большой беседы, которая выходит далеко за рамки данной статьи.

2. Соглашения о вызовах.

Итак, начнём с самого низкого уровня, посмотрим, каким образом компилятор формирует ассемблерный код вызова функции. Собственно во всех языках это происходит одинаково: по ассемблерной команде CALL процессор запоминает в стеке текущий адрес и управление передаётся в начало функции. В конце функция исполняет инструкцию RET, которая извлекает сохранённый адрес из стека обратно и дальше процессор продолжает исполнять программу именно с этого адреса. Но вызвать функцию в большинстве случаев мало. Надо ей ещё передать аргументы. Собственно правила их передачи и называют соглашениями о вызовах.

Итак, куда мы можем «запихнуть» параметры? Таких мест немного: это регистры процессора и всё тот же стек. Наиболее общепринятым соглашением считается передача всех параметров именно через стек. Значит, мы последовательно отправляем параметры в стек. Стоп! Вот и первое разногласие: одни языки начинают с первого параметра, другие — с последнего. К счастью, VB и C придерживаются здесь одного мнения: с последнего. А вот в Fortran изначально было принято делать как раз наоборот, правда MS PowerStation Fortran использует тот же порядок аргументов, что принят в C. Так, занесли параметры в стек, выполнили функцию, и теперь параметры нужно убрать из стека, дабы вернуть его в начальное состояние. Мелочь, казалось бы, одна команда процессора, но тут-то и кроется главное разногласие! А именно, кто это должен делать: вызывающая или вызываемая программа?

Наиболее естественным кажется, что удалять параметры из стека должна вызываемая функция. Большинство языков, в частности наш любимый Visual Basic ;-), именно так и поступают. Более того, практически все функции Win32 API следуют именно этому соглашению. Его используют и такие «классические» языки, как Fortran и Pascal. Собственно его чаще всего и называют «паскалевское соглашение о вызовах».

Язык C имеет особое, отлично от всех остальных, мнение по вопросу о том, кто должен очищать стек, как впрочем, и по многим другим вопросам. В С по умолчанию принято, что стек очищает вызывающая программа. Это позволяет делать, например такие навороты, как функции с переменным числом аргументов, но чуть-чуть увеличивает размер программы.

Итак, существуют два альтернативных соглашения: «С» и «Pascal». Поэтому при вызове «чужой» функции одна из сторон должна принять и чужое соглашение о вызовах. Взглянем, как это делается с разных сторон: со стороны C и со стороны VB.

2.1 Соглашения о вызовах в С/С++.

Компилятор C умеет работать и с тем и с другим соглашением о вызовах. Во-первых, специальными ключами командной строки компилятора можно изменить тип соглашения о вызовах, используемого по умолчанию, что я крайне не рекомендую делать, поскольку это поменяет тип всех функций (за исключением тех, тип вызова которых указан явно, см. ниже), в том числе и библиотечных, и ваша программа просто перестанет работать. Во-вторых, в объявлении функции можно в явном виде указать, какому соглашению она должна следовать, с помощью специального ключевого слова: __stdcal для паскалевского и __cdecl для сишного. Например:

__declspec(dllexport) unsigned int __stdcall ServerConnect(const char* name, const char* passwd, int mode);

Кстати, в документации к Visual C, ключевые слова __stdcal и __cdecl помечены, как “Microsoft Specific”. Однако насколько я знаю, другие компиляторы используют их же. Впрочем, для надёжности лучше свериться с документацией.

Далее, с соглашениями о вызовах теснейшим образом связаны соглашения о наименованиях. Думаю, ни для кого не секрет, что в obj, lib и dll файлах функции имеют несколько иные имена, нежели в исходном тексте. К именам __cdecl функций компилятор C добавляет в начале символ подчёркивания ‘_’, а к __stdcal тот же символ ‘_’ в начале плюс в конце символ ‘@’ и число байт, занимаемых аргументами. Таким образом, наша функция ServerConnect в DLL будет называться “_ServerConnect@12”. Это соглашение является стандартом для большинства языков, в том числе Fortran.

Кроме __stdcal и __cdecl существует ещё и __fastcall. Это соглашение о вызовах, при котором часть параметров передается через регистры. Но как ни странно, даже для разных компиляторов C оно различается. Конкретный пример взаимонепонимания: Visual C и Watcom. Так что о вызове таких функций из других языков речи, как правило, вообще не идёт.

Теперь замечания относительно C++. В нём глобальные функции используют теже соглашения о вызовах, что и в чистом C. Однако С++ имеет дурную привычку «разукрашивать» имена функций в объектных файлах. Вот например как он обозвал стандартную функцию void terminate(void): “?terminate@@YAXXZ”. Чтобы имена были такими же, как и в C, к объявлению функции нужно добавить «extern "C"»:

extern "C" __declspec(dllexport) unsigned int __stdcall ServerConnect(const char* name, const char* passwd, int mode);

С функциями-членами классов, особенно нестатическими хуже. В MSDN вообще говорится, что последние следуют особому соглашению о вызовах, “thiscall”. На самом деле это разновидность вышеперечисленных, только к аргументам добавляется ещё один скрытый параметр: this. extern "C" функции-члены объявлять нельзя. И что хуже всего, соглашения о наименованиях функций C++ у разных компиляторов разные. Так что не только для взаимодействия с другими языками, но даже с другими компиляторами можно использовать только глобальные функции, объявленные как extern "C".

К счастью, все эти ограничения не касаются COM. Наоборот, в нём вы просто вынуждены использовать объектную модель. Поэтому, если вы разрабатываете проект на C++, то для взаимодействия между модулями оптимальным выбором будет именно COM, поскольку он позволяет использовать все преимущества ООП. «Но это - уже совсем другая история...»

2.2 Соглашения о вызовах в VB.

А как же обстоят дела с соглашениями о вызовах в Visual Basic? Ещё раз оговорюсь, что речь идёт лишь о вызовах функций, расположенных во внешних DLL. Напомню, чтобы вызвать такую функцию, надо объявить её оператором Declare. К сожалению, в документации ничего не сказано о том, должны ли эти функции использовать соглашения Pascal или C. Как я выяснил, по умолчанию считается, что функции в DLL следуют соглашению Pascal, то есть в C тексте должны быть объявлены как __stdcall.

Можно ли вызвать из VB __cdecl функцию? Вопрос любопытный. Может быть даже и можно. На мысль об этом наводит упоминание о ключевом слове VB ‘Cdecl’. Но пользоваться им я так и не научился.

Что касается соглашений о наименованиях, то в VB проблема решается очень просто. С помощью ключевого слова Alias можно указать истинное имя функции в DLL:

Private Declare Function ServerConnect Lib "ServerLib.dll" Alias "_ServerConnect@12" (ByVal name As String, ByVal passwd As String, ByVal mode As Long) As Long

Как именно функция называется в DLL легко вычислить исходя из числа и размера параметров. А можно просто посмотреть его утилитой DUMPBIN из комплекта VC или аналогичной.

Опять замечу, что для взаимодействия с внешними модулями у VB существует механизм ActiveX, в свою очередь основанный на COM. Только он может обеспечить полноценное взаимодействие VB с другими системами. Использовать механизм вызова функций из DLL имеет смысл пожалуй только в двух случаях: а) у вас очень простой проект и для организации взаимодействия достаточно пары простых функций, б) вам нужны функции из чужой, разработанной не вами библиотеки, модифицировать которую у вас нет возможности. В частности, это касается системных библиотек, например kernel32.dll или user32.dll Таким методом из VB можно напрямую вызывать функции Win32 API.

3 Передача параметров.

Как? Опять передача параметров? Вся предыдущая часть была посвящена этому. Да! Но раньше мы говорили о том, кто должен очищать стек, теперь же обсудим, что же именно в него помещается. То есть мы поднимемся на один логический уровень вверх и рассмотрим, как именно представляются параметры различных типов.

3.1 Передача по значению и по ссылке.

В большинстве языков высокого уровня, как компилируемых, так и интерпретируемых принята передача параметров по ссылке (by reference). Здесь классическим примером является Fortran. При этом вызывающая функция предоставляет вызываемой доступ к переменной, передаваемой в качестве параметра. Благодаря этому, вызываемая функция может изменить переменную, переданную в качестве аргумента. Часто это используется для того, чтобы возвращать результаты.

У языка C особое мнение и на этот счёт. Здесь принято передавать в функцию копию переменной. Вызываемая функция не может изменить переменную в вызывающей. По учёному это называется «передача параметра по значению» (by value).

Если читатель сталкивается с этим в первый раз, он наверно уже запутался :-) Поэтому проиллюстрирую сказанное простейшим примером на VB:

Private Sub A(ByRef x As Integer)

' попробуте заменить ByRef на ByVal

x = 1

End Sub


Private Sub Test()

Dim i As Integer

i = 9

Call A(i)

' теперь i стало 1

MsgBox I

End Sub

Если передача осуществляется по ссылке, как это и принято по умолчанию в VB, переменная i изменит своё значение после вызова A, а если по значению то нет.

При взаимодействии языков, использующих по умолчанию одинаковый метод передачи параметров (скажем VB и Fortran) проблем не возникает. А вот как заставить VB и C понять друг друга. Посмотрим, как это всё выглядит на уровне ассемблера? При передаче по значению в стек действительно помещается именно значение переменной, то есть в стеке как раз и создаётся копия переменной. При передаче по ссылке в стек на самом деле помещается не сама переменная, а её адрес в памяти, или выражаясь в терминах C, указатель на переменную.

Несомненным достоинством языка C является то, что здесь вещи называются своими именами. Если мы передаём переменную, значит её значение и передаётся в функцию. Если мы хотим, чтобы вызываемая программа имела возможность изменить некую переменную, мы передаём указатель на неё. Как вы уже поняли, при передаче аргумента по ссылке в других языках происходит тоже самое, только указатель скрыт от глаз программиста.

В C++ кстати в дополнение к обычным указателям C есть также ссылочные переменные, но за ними скрываются всё те же указатели.

VB по умолчанию передаёт параметры по ссылке, для ясности можно поставить ключевое слово ByRef. Но он может передавать их и по значению. Для этого предназначено ключевое слово ByVal.

Что же всё это означает на практике, когда мы вызываем из VB функцию DLL, написанную на C? Очень просто! Если функция C принимает в качестве параметра просто переменную, в VB мы должны описать этот параметр ByVal, а если указатель на неё, то ByRef. Пример:

// в с++

extern "C" __declspec(dllexport) __stdcall int DllFunction( int a, int* pb, int& c );


Rem в Visual Basic

Declare Function DllFunction Lib "Test.dll" Alias "_DllFunction@12" (ByVal a As Long, ByRef b as Long, ByRef c as Long ) As Long

Кстати, обратите внимание ещё на один момент, из-за которого легко может возникнуть путаница. Cишный тип int в большинстве современных компиляторов имеет размер 32 бита, что соответствует VB типу Long. Размер VBшного типа Integer составляет 16 бит, в C это - short. Для справки привожу таблицу соответствия типов разных языков. Здесь под C/C++ и Fortran подразумеваются современные компиляторы под 32х битную платформу. Но учтите, что в стандарт языка это не входит. Если вы, скажем, возьмёте старый добрый Turbo C под DOS, там тип int будет иметь размер 16 бит. Так что почаще сверяйтесь с документацией.

Тип \ ЯзыкC/C++Visual BasicFortran
целое 8 битcharByteINTEGER*1
целое 16 битshortIntegerINTEGER*2
целое 32 битint, longLongINTEGER, INTEGER*4
с плавающей точкой 32 битfloatSingleREAL, REAL*4
с плавающей точкой 64 битdoubleDoubleDOUBLE, REAL*8

3.2 Передача строковых переменных между C и VB

Слава богу, что представление чисел, и целых и с плавающей точкой, определяются не языком программирования, а архитектурой процессора. Отличаются только названия типов. Хуже дело обстоит со строковыми переменными. С ними всегда возникают проблемы. Интерфейс VB-C — не исключение. К счастью, в документации по VB эта проблема освещена достаточно подробно, но увы, не полностью. Здесь я вкратце перескажу MSDN и добавлю кое-что от себя.

Основной вопрос для различных представлений строки, это как задать её длину? Возможных решений два: либо как в Fortran вместе со строкой создать дополнительную целую переменную, где и будет храниться её длина, либо как в C определить специальный символ, конец строки, '\0'. VB, как самый умный, делает и то и другое. VB переменная типа String представляет, как и в С, указатель на строку оканчивающуюся нулём. Но на самом деле эта строка содержится внутри некой структуры данных. Хуже того, эта структура связана с другими внутренними структурами данных, в частности с системой распределения памяти VB. К чему это приводит?

С одной стороны, функции C могут относительно свободно работать со строчками VB. Они могут рассматривать VB String, как обычный указатель на строчку, оканчивающуюся нулём. Они даже могу смело модифицировать содержимое этой строчки, главное не вылезти случайно за её границы. В программе на VB надо заранее позаботиться о том, чтобы строчка была достаточного размера. А вот модифицировать сам указатель никак нельзя, иначе мы разрушим целостность внутренних структур данных VB. Поэтому, в частности, String передаётся в DLL ByVal.

Увы, обратная теорема не верна! Если C функция возвращает указатель на строку, VB не способен её воспринять и использовать как-либо разумно. Всё что можно сделать, это запомнить этот указатель в 32 битной переменной, то есть Long. Потом этот указатель можно будет передать для обработки другой внешней функции. Но внутри VB непосредственно использовать как строчку её нельзя.

Таким образом, если нам нужно передавать из DLL в VB текстовые данные, лучше всего это организовать подобно тому, как это принято в Win32 API. Сначала вызывается функция DLL, возвращающая длину строчки, в VB создаётся переменная String соответствующей длины, затем вызывается вторая DLL функция, которая копирует строчку в указанный буфер. Проиллюстрируем это примером. Здесь саму строку и её длину выдаёт одна и та же функция:

// C++

extern "C" __declspec(dllexport) __stdcall unsigned int GetResult( char* _pBuffer, unsigned int _nBufferSize ) {

// пусть это будет строчка, которую надо передать в VB

static const char szResult[] = "Visual Basic - rules forever ;-)";


unsigned int nStringLenght = strlen( szResult );

if( _nBufferLength >= nStringLenght ) {

// если буффер достаточного размера, копируем строчку

memcpy( _pBuffer, szResult, nStringLenght );

}

// возвращаем размер строчки

return nStringLenght;

}


Rem Visual Bacic

Private Declare Function GetResult Lib "SomeLib.dll" Alias "_GetResult@8" ( ByVal buffer As String, ByVal BufferSize As Long )


Private Sub Test()

Dim BufSize As Long

Dim result As String

' извлекаем длину строки

BufSize = GetResult( result, 0 )

' создаём буффер соответствующего размера

result = String( BufSize, " " )

' копируем в него строчку

GetResult( result, BufSize )


MsgBox result

End Sub

Что же всё-таки делать, если внешняя функция возвращает указатель на сточку, с которой надо работать в VB, а модифицировать эту DLL никак нельзя? Оказывается, решение есть. Нужно опять-таки создать переменную String соответствующей длины, а чтобы определить необходимый ей размер и скопировать в неё данные использовать функции Win32 API, благо из VB они вызываются опять-таки как функции DLL. Вот код:

Rem ////////// WINDOWS system functions ////////////////////////

Private Declare Function DllStringLength Lib "kernel32" Alias "lstrlenA" (ByVal pCOutString As Long) As Long

Private Declare Sub CopyDllString Lib "kernel32" Alias "RtlMoveMemory" (ByVal sDestination As String, ByVal pCOutString As Long, ByVal Length As Long)


Public Function DllString2VbString(dllString As Long) As String

Dim nStringLength As Long

nStringLength = DllStringLength(dllString)


Dim sVbString As String

sVbString = String(nStringLength, "*")

Call CopyDllString(sVbString, dllString, nStringLength)

DllString2VbString = sVbString

End Function

Обратите внимание, что везде здесь указатель на внешнюю строчку обозначается как Long. Это сделано для того, чтобы VB работал с ним просто, как с некой абстрактной 32-битной переменной. В противном случае он непременно попытается преобразовать число-указатель в текстовый вид.

3.3 Передача переменных между C и Fortran

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

SUBROUTINE HBOOK1( ID, CHTITL, NX, XMI, XMA, VMX)

CHARACTER*(*) CHTITL

...

А вот как будет выглядеть прототип этой функции в С++ коде:

extern "C" void __stdcall HBOOK1( const int& ID, const char* pCHTITL, unsigned int CHTITL_LEN, const int& NX, const float& XMI, const float& XMA, const float& VMX );

Текстовая переменная CHTITL на самом деле оказалась сразу двумя параметрами: pCHTITL и CHTITL_LEN. Известно, что HBOOK1 использует все свои параметры только как входные, поэтому они объявлены как const. Напомню, что в Fortran принято передавать параметры по ссылке, поэтому в C++ тоже удобно использовать ссылки. Можно переписать это на манер "чистого C", используя указатели:

extern "C" void __stdcall HBOOK1( const int* pID, const char* CHTITL, unsigned int CHTITL_LEN, const int* pNX, const float* pXMI, const float* pXMA, const float* pVMX );

Не забудьте также, что строка переданная из Fortran в С не заканчивается нулём!

Данный пример взят из жизни. Здесь использовались MS Visual C 4.0 и MS PowerStation Fortran 4.0. Очень похоже дело обстояло при взаимодействии GNUсных С и F2C. К сожалению, различные диалекты Fortran отличаются друг от друга значительно сильнее, чем различные реализации С. В частности порядок параметров может быть обратным. Поэтому не могу гарантировать, что с другими компиляторами приведённый пример не потребует изменений.

4 Заключение

Здесь я постарался собрать воедино многие сведения. Но как всегда "нельзя объять необъятное". Весьма вероятно, что для вашей конкретной задачи решение придётся искать самостоятельно. Рекомендую в первую очередь обраться к MSDN. Наиболее важные разделы, касающиеся нашей темы это:

Visual C++ Documentation ==> Using Visual C++ ==> Visual C++ Programmer's Guide ==> Adding Program Functionality ==> Details ==> Mixed-Language Programming Topics

Visual Basic Documentation ==> Using Visual Basic ==> Component Tools Guide ==> Accessing DLLs and the Windows API

Если у вас нет ни MSDN, ни Visual Studio, не огорчайтесь. Библиотека MSDN полностью доступна на сайте Microsoft (http://msdn.microsoft.com/).

RTFM, RTFM и ещё раз RTFM :-) Но к сожалению, далеко не всегда нужный ответ удаётся найти из документации. В этом случае вас может спасти только творческий подход к проблеме :-)

© 2001 Алексей Курзенков

Связаться со мной можно по адресу:


Внимание!!! Любое копирование данной статьи, перепечатка, публикация на других сайтах возможна только с согласия автора. Для публикации в любых печатных изданиях необходимо письменное согласие редакции журнала "Программист".