Привет, Хабр! Меня зовут Михаил, я backend-разработчик в SimbirSoft. Время от времени я сталкиваюсь с понятием «двоичный», он же «бинарный» интерфейс приложений, или просто ABI (application binary interface). Все найденные мной материалы на эту тему были либо очень скудны, либо вели к многостраничным эпопеям, которые напоминали сборную солянку из описания, например, архитектуры процессора x86, сдобренную стандартом С++.
Но потом я наткнулся на перевод публикации Тита Винтерса в рабочей группе 21 (WG21) — комитета по стандартизации языка C++. В ней он поднимает вопрос поддержки обратной бинарной совместимости. Значит, вопрос актуальный — это и стало мотивацией для написания этой статьи. В ней я сфокусируюсь на примерах и практике использования С++ в других языках программирования. Материал будет полезен middle+ и senior-разработчикам, а также всем, кто хочет сделать гибкий, долгоживущий, легко настраиваемый под заказчика продукт.
Так что заваривайте чай, запасайтесь быстрыми углеводами — вас ждёт увлекательное погружение в мир низкоуровневого программирования. Начнём с теории, затем рассмотрим несколько практических примеров.

Пишем модуль на С++ и применяем его в других языках
ABI — это набор соглашений, которые описывают двоичный интерфейс приложения. В частности, он регламентирует использование стека и регистров процессора, порядок передачи аргументов и возвращаемого значения для функций, размеры базовых типов и многое другое. В целом, ABI описывает детали реализации взаимодействия приложений между собой и между операционной системой (платформой).
Для каждого языка программирования определён свой ABI, но некоторые вещи характерны для всех языков. Например, как компилятор формирует код вызова функции? В ассемблере есть команда CALL, которая передаёт управление подпрограмме (функции), запоминая при этом в стеке смещение к точке возврата. В конце, функция исполняет инструкцию, которая извлекает этот сохранённый адрес из стека обратно, и процессор продолжает исполнять программу именно с этого адреса.
Но если вызов для всех языков осуществляется одинаково, то далее возможны существенные различия. Ведь кроме вызова самой функции, в нее часто необходимо передать различные параметры. Казалось бы, ничего особенного, но нет. Параметры, передаваемые функции, можно положить сразу в регистр, а можно отправить в стек. Общепринятым соглашением считается передача всех параметров именно через стек.
Но и здесь не всё так однозначно — можно ведь отправлять, начиная с последнего переданного параметра, а можно и наоборот. И первым в стек или регистр (это уже зависит от реализации) полетит первый переданный в функцию. К тому же, необходимо позаботиться и об удалении элементов из стека: кто это будет делать — вызывающий или вызываемый? Чаще всего удалением занимается вызываемый, но это не всегда так. Например, в языке С принято, что стек очищает вызывающая программа. Для простоты восприятия, оформим в виде таблицы возможные варианты для компилятора Visual C/C++.
|
Название |
Кто очищает стек |
Передача параметров |
|
__cdecl |
Вызывающая функция |
Параметры помещаются в стек в обратном порядке (справа налево) |
|
__clrcall |
н/д |
Параметры загружаются в стек выражений CLR по порядку (слева направо) |
|
__stdcall |
Вызываемая функция |
Параметры помещаются в стек в обратном порядке (справа налево) |
|
__fastcall |
Вызываемая функция |
Хранятся в регистрах, затем помещаются в стек |
|
__thiscall |
Вызываемая функция |
Отправлено в стек; this указатель, хранящийся в ECX |
|
__vectorcall |
Вызываемая функция |
Хранятся в регистрах, затем помещаются в стек в обратном порядке (справа налево) |
Необходимо также учитывать, что в том же С или С++ передача параметров может быть как по ссылке, так и по значению. Ассемблерный код для этих случаев будет разным, значит и для ABI это тоже имеет значение.
Последнее, что стоит упомянуть — типы данных. Естественно, речь идет о встроенных типах. В С и С++ размеры типов могут варьироваться в зависимости от платформы. Думаю, не стоит объяснять, что несовместимость по размерам типов делает ABI несовместимым для таких платформ. Классический пример: попробуйте запустить 64-разрядную программу, написанную под Windows x64, в 32-разрядной версии операционки. Вряд ли у вас что-то получится.
Разумеется, это далеко не всё, что входит в ABI. Например, документ под названием System V Application Binary Interface AMD64 Architecture Processor Supplement (With LP64 and ILP32 Programming Models) содержит в себе больше ста страниц увлекательного погружения в мир регистров, примитивных типов данных, их размеров и прочих выравниваний, а также исключений. Просто рай для искушённого гурмана. Но мы не будем акцентировать на этом внимание — это тема для отдельной статьи.
Показываем на практике
Представим, что у нас есть готовая программа и мы хотим расширить её функциональность. Как это сделать? Можно открыть исходники и дописать недостающую фичу. Пересобираем проект — профит! Ах, точно — забыли про ещё одну функциональность. Реализуем, пересобираем, готово! Но нужна еще одна фича. Опять реализуем, пересобираем! Забыли про еще одну деталь. Снова реализуем… Так, стоп, что-то здесь не так, согласны? Хотелось бы упростить постоянную доработку и пересборку — оставить минимально возможный функционал, а остальное добавлять в виде загружаемой библиотеки. Ничего не напоминает? Ведь это то, что сейчас зовётся плагином. Естественно, они могут основываться на разных технологиях, но большинство из тех, с которыми мне приходилось работать, были, по сути, обычными динамическими библиотеками.
Проще говоря, мы пишем приложение на том же С++, добавляем в него возможность загрузки динамических библиотек и получаем расширение функционала без пересборки основного приложения. Например, всем известный Notepad++ работает с плагинами именно так. Причём здесь ABI? Как раз именно правильная реализация двоичного интерфейса позволит нам без проблем использовать единожды написанную динамическую библиотеку для различных версий одной платформы. Помните о тех вещах (порядок передачи параметров в функцию, размер типов), которые я упоминал в начале статьи? Если библиотека и основное приложение собраны одним и тем же компилятором на одной и той же платформе, про ABI можно вообще не вспоминать. Всё и так заработает без проблем. Но если, к примеру, собрать основное приложение при помощи MinGW, а библиотеку — при помощи MSVC, результат будет не таким очевидным.
А что, если связать друг с другом совсем разные языки программирования? Вызов С++ библиотеки из программы на С и наоборот — слишком банально и неинтересно. Давайте вызовем С++ библиотеку из Java и из Python. А в качестве полезного функционала наша С++ библиотека будет возвращать число Пи вплоть до 10 000 знаков после запятой.
Приступим!
Создадим Java-файл и в нём — соответствующий класс NativeLib.java:
public class NativeLib { static { System.loadLibrary("piValueLib"); } public native String getPiValue(); // <--- native method }
Статический метод System.loadLibrary(“название загружаемой библиотеки”) будет загружать нашу динамическую библиотеку (piValueLib.dll).
Эту функцию будем вызывать из piValueLib.dll:
public native String getPiValue(); // <--- native method
Теперь перейдём к разработке самой .dll.
Дальнейшие действия я делал в командной строке MinGW 7.3.0 64-bit. Приведу сами команды и порядок их вызова (вызываем из папки, где всё лежит или пишем пути до нужных файлов).
Компилируем и создаём заголовочник (.h) для плюсовой динамической библиотеки.
javac -h . NativeLib.java
Получаем файл со следующим содержимым:
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class NativeLib */ #ifndef _Included_NativeLib #define _Included_NativeLib #ifdef __cplusplus extern "C" { #endif /* * Class: NativeLib * Method:getPiValue * Signature: ()Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_NativeLib_getPiValue (JNIEnv *, jobject); #ifdef __cplusplus } #endif #endif
Объявление есть, теперь создадим файл NativeLib.cpp, в котором реализуем алгоритм вычисления числа Пи с точностью до 10 000 знаков после запятой (найден на просторах сети):
Код:
#include "NativeLib.h" #include <stdio.h> #include <malloc.h> #include <math.h> #include <sstream> #include <string> using std::ostringstream; using std::string; ostringstream stringBuffer; long B = 10000; long LB = 4; long MaxDiv = 450; void SetToInteger(long n, long *x, long Integer) { long i; for (i = 1; i < n; i++) x[i] = 0; x[0] = Integer; } long IsZero(long n, long *x) { long i; for (i = 0; i < n; i++) if (x[i]) return 0; return 1; } void Add(long n, long *x, long *y) { long carry = 0, i; for (i = n - 1; i >= 0; i--) { x[i] += y[i] + carry; if (x[i] < B) carry = 0; else { carry = 1; x[i] -= B; } } } void Sub(long n, long *x, long *y) { long i; for (i = n - 1; i >= 0; i--) { x[i] -= y[i]; if (x[i] < 0) { if (i) { x[i] += B; x[i - 1]--; } } } } void Mul(long n, long *x, long q) { long carry = 0, xi, i; for (i = n - 1; i >= 0; i--) { xi = x[i] * q; xi += carry; if (xi >= B) { carry = xi / B; xi -= (carry*B); } else carry = 0; x[i] = xi; } } void Div(long n, long *x, long d, long *y) { long carry = 0, xi, q, i; for (i = 0; i < n; i++) { xi = x[i] + carry * B; q = xi / d; carry = xi - q * d; y[i] = q; } } void arccot(long p, long n, long *x, long *buf1, long *buf2) { long p2 = p * p, k = 3, sign = 0; long *uk = buf1, *vk = buf2; SetToInteger(n, x, 0); SetToInteger(n, uk, 1); /* uk = 1/p */ Div(n, uk, p, uk); Add(n, x, uk); /* x = uk */ while (!IsZero(n, uk)) { if (p < MaxDiv) Div(n, uk, p2, uk); /* One step for small p */ else { Div(n, uk, p, uk); /* Two steps for large p (see division) */ Div(n, uk, p, uk); } /* uk = u(k-1)/(p^2) */ Div(n, uk, k, vk); /* vk = uk/k */ if (sign) Add(n, x, vk); /* x = x+vk */ else Sub(n, x, vk); /* x = x-vk */ k += 2; sign = 1 - sign; } } void Print(long n, long *x) { long i; for (i = 1; i < n; i++) stringBuffer << x[i]; } JNIEXPORT jstring Java_NativeLib_getPiValue(JNIEnv* env, jobject thisObject) { long NbDigits = 10000, NbArctan; long p[10], m[10]; long size = 1 + NbDigits / LB, i; long *Pi = (long *)malloc(size * sizeof(long)); long *arctan = (long *)malloc(size * sizeof(long)); long *buffer1 = (long *)malloc(size * sizeof(long)); long *buffer2 = (long *)malloc(size * sizeof(long)); NbArctan = 3; m[0] = 12; m[1] = 8; m[2] = -5; p[0] = 18; p[1] = 57; p[2] = 239; SetToInteger(size, Pi, 0); for (i = 0; i < NbArctan; i++) { arccot(p[i], size, arctan, buffer1, buffer2); Mul(size, arctan, abs(m[i])); if (m[i] > 0) Add(size, Pi, arctan); else Sub(size, Pi, arctan); } Mul(size, Pi, 4); Print(size, Pi); free(Pi); free(arctan); free(buffer1); free(buffer2); string endResult = stringBuffer.str(); return env->NewStringUTF(endResult.c_str()); }
Обратите внимание вот на эту функцию:
JNIEXPORT jstring Java_NativeLib_getPiValue(JNIEnv* env, jobject thisObject) { long NbDigits = 10000, NbArctan; long p[10], m[10]; long size = 1 + NbDigits / LB, i; long *Pi = (long *)malloc(size * sizeof(long)); long *arctan = (long *)malloc(size * sizeof(long)); long *buffer1 = (long *)malloc(size * sizeof(long)); long *buffer2 = (long *)malloc(size * sizeof(long)); NbArctan = 3; m[0] = 12; m[1] = 8; m[2] = -5; p[0] = 18; p[1] = 57; p[2] = 239; SetToInteger(size, Pi, 0); for (i = 0; i < NbArctan; i++) { arccot(p[i], size, arctan, buffer1, buffer2); Mul(size, arctan, abs(m[i])); if (m[i] > 0) Add(size, Pi, arctan); else Sub(size, Pi, arctan); } Mul(size, Pi, 4); Print(size, Pi); free(Pi); free(arctan); free(buffer1); free(buffer2); string endResult = stringBuffer.str(); return env->NewStringUTF(endResult.c_str()); }
Именно её результат мы будем возвращать из библиотеки.
Весь код, естественно, на С++. Заголовочный файл есть, файл с определениями — тоже, можем собрать библиотеку. Не забудьте проверить, чтобы в файле NativeLib.cpp была строка
#include "NativeLib.h"
и сигнатуры функции
JNIEXPORT jstring Java_NativeLib_getPiValue(JNIEnv* env, jobject thisObject)
в NativeLib.h и в NativeLib.cpp полностью совпадали. Иначе получите ошибку линковки.
Команда для получения объектного файла:
g++ -c -I"C:\Program Files\Eclipse Adoptium\jdk-17.0.3.7-hotspot\include" -I"C:\Program Files\Eclipse Adoptium\jdk-17.0.3.7-hotspot\include\win32" NativeLib.cpp -o NativeLib.o
Обратите внимание — пути до файлов Java jdk у вас будут свои.
Команда для создания piValueLib.dll:
g++ -shared -o piValueLib.dll NativeLib.o -Wl,--add-stdcall-alias
Теперь всё собрано, скомпилировано, .dll есть — можно запускать проект:
java -cp . -Djava.library.path="C:\Users\user\Desktop\JNI\JNIProject\src" Main.java
Здесь в path указываем путь до динамической библиотеки. Результат будет примерно таким:

Если не верите, что там 10 000 знаков после запятой — можете посчитать.
Для адептов компиляторов от Microsoft — то же самое можно повторить и с ним. Я экспериментировал с x64 Native Tools Command Prompt for VS 2019. Скомпилировать Java-файл и сделать заголовочник для плюсовой библиотеки:
javac -h . NativeLib.java
Компиляция исходника плюсовой либы (получение объектного файла):
cl /c NativeLib.cpp
Получение .dll-файла:
cl /LD NativeLib.cpp
Запуск Java приложения:
java -cp . -Djava.library.path="C:\Users\user\Desktop\JNI\JNIProject\src" Main.java
Если возникнут проблемы — за подробностями всегда можно обратиться на сайт с документацией Microsoft. Или вызвать справку для командной строки компилятора:
cl /?
Ну что, получилось? Если нет — скорее всего, вы увидите ошибки вида «Ссылка на неразрешенный внешний символ». Это значит, что вы нарушили одно из соглашений ABI (ситуации с ошибками синтаксиса опустим — допустим, вы всё сделали правильно).
С Java разобрались: .dll собрали, код внедрили, результат получен. Займёмся пресмыкающимся? Внедрим нашу библиотеку в Python.
Но сначала нужно внести небольшие изменения в код плюсовой .dll-ки. Изменим сигнатуру (принимаемые параметры и возвращаемый результат) функции, возвращающей результат в питоновский код. Теперь нам не надо работать с JNIEnv *env и jobject. Поэтому эти параметры можно убрать. В результате код нашей .dll-ки будет примерно следующим (собирал в Visual Studio, а не в командной строке, поэтому присутствует функция DllMain):
#include <malloc.h> #include <math.h> #include <sstream> #include <string> #include <Windows.h> using std::ostringstream; using std::string; ostringstream stringBuffer; string endResult; char* stroka; long B = 10000; long LB = 4; long MaxDiv = 450; void SetToInteger(long n, long* x, long Integer) { long i; for (i = 1; i < n; i++) x[i] = 0; x[0] = Integer; } long IsZero(long n, long* x) { long i; for (i = 0; i < n; i++) if (x[i]) return 0; return 1; } void Add(long n, long* x, long* y) { long carry = 0, i; for (i = n - 1; i >= 0; i--) { x[i] += y[i] + carry; if (x[i] < B) carry = 0; else { carry = 1; x[i] -= B; } } } void Sub(long n, long* x, long* y) { long i; for (i = n - 1; i >= 0; i--) { x[i] -= y[i]; if (x[i] < 0) { if (i) { x[i] += B; x[i - 1]--; } } } } void Mul(long n, long* x, long q) { long carry = 0, xi, i; for (i = n - 1; i >= 0; i--) { xi = x[i] * q; xi += carry; if (xi >= B) { carry = xi / B; xi -= (carry * B); } else carry = 0; x[i] = xi; } } void Div(long n, long* x, long d, long* y) { long carry = 0, xi, q, i; for (i = 0; i < n; i++) { xi = x[i] + carry * B; q = xi / d; carry = xi - q * d; y[i] = q; } } void arccot(long p, long n, long* x, long* buf1, long* buf2) { long p2 = p * p, k = 3, sign = 0; long* uk = buf1, * vk = buf2; SetToInteger(n, x, 0); SetToInteger(n, uk, 1); Div(n, uk, p, uk); Add(n, x, uk); while (!IsZero(n, uk)) { if (p < MaxDiv) Div(n, uk, p2, uk); else { Div(n, uk, p, uk); Div(n, uk, p, uk); } Div(n, uk, k, vk); if (sign) Add(n, x, vk); else Sub(n, x, vk); k += 2; sign = 1 - sign; } } void Print(long n, long* x) { long i; for (i = 1; i < n; i++) stringBuffer << x[i]; } extern "C" { __declspec(dllexport) char* getPiValue(); } BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: case DLL_THREAD_ATTACH: break; case DLL_THREAD_DETACH: break; case DLL_PROCESS_DETACH: break; } return TRUE; } char * getPiValue() { long NbDigits = 10000, NbArctan; long p[10], m[10]; long size = 1 + NbDigits / LB, i; long* Pi = (long*)malloc(size * sizeof(long)); long* arctan = (long*)malloc(size * sizeof(long)); long* buffer1 = (long*)malloc(size * sizeof(long)); long* buffer2 = (long*)malloc(size * sizeof(long)); NbArctan = 3; m[0] = 12; m[1] = 8; m[2] = -5; p[0] = 18; p[1] = 57; p[2] = 239; SetToInteger(size, Pi, 0); for (i = 0; i < NbArctan; i++) { arccot(p[i], size, arctan, buffer1, buffer2); Mul(size, arctan, abs(m[i])); if (m[i] > 0) Add(size, Pi, arctan); else Sub(size, Pi, arctan); } Mul(size, Pi, 4); Print(size, Pi); free(Pi); free(arctan); free(buffer1); free(buffer2); endResult = stringBuffer.str(); stroka = const_cast<char*>(endResult.c_str()); return stroka; }
Компилируем и на выходе получаем, скажем, piValueLib.dll. Название может быть любым.
Далее нам нужно написать код на Python, вызывающий нашу библиотеку:
import ctypes lib_dll = ctypes.CDLL(r"C:\Users\user\Desktop\PycharmProjects\pythonProject\piValueLib.dll") # Указываем, что функция возвращает char * lib_dll.getPiValue.restype = ctypes.c_char_p # Необходимо строку привести к массиву байтов, затем полученный массив байтов приводим к строке. #print('pi value from .dll: ', lib_dll.getPiValue().decode("utf-8")) s = lib_dll.getPiValue().decode("utf-8") print('pi value from .dll: ', s) # небольшая функция для def parse_string(string: str) -> str: new_string = "" for letter_index in range(len(string)): if letter_index % 100 == 0 and letter_index != 0: new_string += "\n" else: new_string += string[letter_index] return new_string print(parse_string(s)) def parse_yet_string(string: str) -> str: return ''.join(letter if index % 200 != 0 else f'{letter}\n' for index, letter in enumerate(string)) # показываем содержимое строки (наше число Пи) print(parse_yet_string(s))
В результате получаем примерно следующее:

Если у вас не получилось и вы уверены, что синтаксических ошибок нет, то вы нарушили одно из соглашений ABI.
Что мы получаем
Вот так можно писать независимые модули и расширять ими основное приложение. И заметьте — всё это может быть написано на совершенно разных языках. Пример выше иллюстрирует, как, по сути, скриптовый язык взаимодействует с компилируемым. И под капотом всего этого всегда скрыт механизм ABI (разумеется, разные передачи через JSON, по сети и прочее в расчёт не берём — речь именно о взаимодействии с бинарным файлом).
Но чтобы подобные механизмы взаимодействия с библиотеками работали нормально, нужно всегда помнить про некоторые вещи. Например:
В заголовочном файле NativeLib.h используется конструкция вида extern «C» { }. Объясню, что это такое.
Есть такое понятие — Name Mangling. Методы и данные в приложениях на языке C++ имеют внутренние или декорированные имена, отличные от их имен в исходном коде. Декорированное имя — это символьная строка, в которой вместе с именем объекта закодирована дополнительная информация о типе, параметрах, соглашении о вызовах и других полезных для компилятора подсказках, которые помогают найти правильный код функции. Но для языка C++ нет общепринятого ABI, при динамической загрузке библиотеки может возникнуть проблема поиска нужных символов из-за декорирования имен. Если библиотека и программа, которая её будет использовать, скомпилированы одним компилятором — то никаких проблем. Но это решение будет, скорее всего, непереносимым. Никто не сможет гарантировать, что эта .dll заработает на другой машине, с точно такой же программой, но скомпилированной другим плюсовым компилятором.
Чтобы избежать подобного, на помощь приходит язык C. Директива препроцессора extern «C» позволяет отключить декорирование имени для экспортируемых интерфейсов библиотеки, и мы можем пользоваться ими так, как если бы они были написаны на C. А с С-шными библиотеками могут работать очень и очень многие языки программирования.
Подведем итоги
В теории можно написать один универсальный модуль, скомпилировать его, и в рамках одной платформы (например, Windows) он сможет работать с разными версиями операционки и с разными языками программирования.
Для обеспечения максимальной бинарной совместимости нужно:
-
Экспортировать только функции с припиской extern «C» (т.е. никаких классов, шаблонов, перегруженных функций, пространств имен и т.д.).
-
Передавать (через границы модулей) только простые типы (встроенные — никаких пользовательских), указатели на них и указатели на функции.
-
Если все же очень нужно передать пользовательский тип, то пусть это будет структура, первым членом которой будет размер этой структуры. Так, по крайней мере, можно проконтролировать выравнивание полей.
-
Не забывать про выделенную память — блоки динамической памяти должны освобождаться всегда в том же модуле, в котором были выделены. То есть, если библиотека возвращает программе-клиенту указатель на блок памяти, выделенный malloc внутри себя, она должна предоставлять специальную функцию для его освобождения (вызывающую внутри себя free), вместо того, чтобы полагаться на вызов free в программе-клиенте.
Надеюсь, материал оказался для вас интересным. Спасибо, что дочитали до конца!
А в этой статье мы рассказывали о тонкостях и нюансах того, как писать стабильный, безопасный и надежный код на C/C++.
Авторские материалы для backend-разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.
ссылка на оригинал статьи https://habr.com/ru/companies/simbirsoft/articles/742486/
Добавить комментарий