Как построить мост между JavaScript и C++ через WASM, или гайд для самых маленьких

от автора

Введение

Всем привет. Сегодня я хочу поговорить об использовании WASM с C++ и разберу, как взаимодействовать с этим всем делом через JavaScript.

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

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

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

В рамках данной статьи не будет подниматься тема производительности и некоторых лучших реализаций, от читателя требуется минимальное понимание JavaScript или TypeScript, и C++ кода.

Что будем делать

  • Сначала установим Emscripten, чтобы, в дальнейшем, компилировать наш С++ код для использования в WASM.

  • Узнаем несколько вариантов соединения TypeScript с WASM-функциями.

  • Разберем несколько примеров функций и разберем некоторые проблемы.

  • Запустим полученный нами результат в NodeJS.

С чем будем работать

  • Для компиляции из Си будем использовать Emscripten — компилятор LLVM-байткода в код JavaScript.

  • Со стороны JavaScript будем использовать TypeScript с Node.JS.

  • Для написания С++ кода рекомендую установить IDE, я использую CLion, и добавлю в него .h файлы из Emscripten для рабочего линтера.

Установка Emscripten

С установкой дела обстоят довольно просто, следуйте документации от разработчиков.

Первый и простой пример

Для интеграции нашей С++ функции, нам, собственно, нужен С++ файл с функцией. (Про бинды мы еще не знаем!)

Наш первый С++ код будет выглядеть так:

Пример функции
#include <emscripten/bind.h>  #ifdef __cplusplus #define EXTERN extern "C" #else #define EXTERN #endif  EXTERN EMSCRIPTEN_KEEPALIVE int add(int a, int b) {     return a + b; }

Разберем его подробнее:

Разбор
#include <emscripten/bind.h>

Буквально как импорт библиотек в JavaScript.

#ifdef __cplusplus #define EXTERN extern "C" #else #define EXTERN #endif

Взял с сайта мозиллы, довольно удобный блок для экспорта.

EXTERN EMSCRIPTEN_KEEPALIVE

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

int add(int a, int b) {     return a + b; }

Простейшая функция на Си, которая принимает a, b и возвращает их сумму в виде целого числа.

Теперь мы должны скомпилировать наш С++ код в WASM и JavaScript обёртку для управления оным:

emcc src/wasm/native.example.cpp -o build/native.example.js -s EXPORTED_FUNCTIONS='[«_malloc», «_free»]’ -s EXPORTED_RUNTIME_METHODS='[«lengthBytesUTF8», «stringToUTF8», «setValue», «getValue», «UTF8ToString»]’ -s MODULARIZE -s ENVIRONMENT=’node’ -Oz

Про EXPORTED_RUNTIME_METHODS и EXPORTED_FUNCTIONS станет понятнее чуть позже, но пусть будет уже сейчас.

На выходе мы получаем в папке build файлы native.example.js и бинарный файл native.example.wasm, которые мы будем использовать в нашем JavaScript коде.

Чтобы создать модуль и использовать его, можно использовать следующий код:

Код инициализации WASM-модуля
const WasmModule = require(path.join(__dirname, '..', 'build', 'native.example.js')); const wasmFile = path.join(__dirname, '..', 'build', 'native.example.wasm');  const createModule = async () => {     const wasmBinary = readFileSync(wasmFile);     return WasmModule({         wasmBinary     }); };

Теперь мы можем получить модуль с функциями из этого WASM-файла и его обёртки.

Код использования функции add
const a = 2; const b = 5;  const result = module._add(a, b); // 7;

Очень важно! Без биндов или дополнительных аргументов командной строки, функции из С++ вызываются с «_».

add() -> _add() — Пример таких названий функций.

В общем, на этом большинство туториалов и заканчивается, оставляя программиста с дном огромного айсберга.

Пример с массивом

С данного примера, мы будем опускать пройденные детали, чтобы уменьшить количество текста

.Создадим следующую функцию:

Функция суммирования массива
EXTERN EMSCRIPTEN_KEEPALIVE int sumArray(int array[], int length) {     std::vector<int> vec(array, array + length);     int sum = 0;     for (int num : vec) {         sum += num;     }     return sum; }

Функция принимает массив чисел и длинну массива, чтож, компилируем и получаем сумму элементов.

Код использования
const sumArray = module._sumArray; const result = sumArray([1, 2, 3, 4, 5], 5); console.log("Сумма массива равна: ", result);

Запускаем функцию, получаем… «Сумма массива равна: 0«.

Почему ноль, ведь мы передали массив и должны получить 15?

На данное возмущение ответ прост — мы не можем (можем, но это немного позже) отдавать WASM-функциям что-то сложнее некоторых примитивов.

Тут и наступает 99% проблем при знакомстве с WASM — передача что-то сложнее интегеров.

И одним из путей решения данной проблемы будет, барабанная дробь, адресная арифметика 🙈.

Очень важно! Несмотря на кросс-платформенность, в WebAssembly используется 32-разрядная модель адресации, что означает, что указатели и индексы имеют 32 бита.

Мы должны использовать функции модуля-обёртки в виде «_malloc» для выделения памяти WASM и «_free» — для освобождения оной. В итоге наш код с шагами будет выглядеть следующим образом:

Правильный пример использования функции
const sumArray = module._sumArray;  // Создаем массив i32; const vec = new Int32Array([1, 2, 3, 4, 5, 6]);   // Выделяем под этот массив память в виде длинна массива * количество байт и получаем ссылку на его начало const arrayPtr = module._malloc(vec.length * vec.BYTES_PER_ELEMENT);      vec.forEach((item, index) => {    // Для каждого нужного адреса копируем значение.      module.setValue(arrayPtr + index * vec.BYTES_PER_ELEMENT, item, 'i32'); }) /**  * ИЛИ  * Если тип входит в существующие типы куч, то тогда можно сделать так  * в куче 32 битных чисел надо начинать с индекса, а не ссылки, а индекс это укзаатель/(32/8) = (4 бита)  *  * module.HEAP32.set(vec, arrayPtr >> 2);  *  * Либо же каждые 4 байта записываем в память значения 4-х байтного числа, как до комментария  */  const result = sumArray(arrayPtr, vec.length); // Должны получить 21  // Освобождаем выделенную память, если не сделали в С++ module._free(arrayPtr);  console.log('Результат функции sumArray:', result); // Результат функции sumArray: 21

…Запускаем код и получаем «Сумма массива равна: 21»!

Получилось! Теперь мы знаем, что при передаче не примитивов, нужно самим передавать значение в WASM-память и передавать указатель на начало этой структуры, в данном случае, массива.

Просто про память

Не буду вдаваться в Computer Science, но предоставлю очень быструю справку.

JavaScript — язык с безопасной памятью, то есть, можно сделать что угодно, но до прямых значений памяти мы добраться не сможем.

Си и С++ — другое дело, и нам, в силу специфики работы с WASM, нужно предоставлять указатели на структуры.

Возьмем пример из sumArray. В результате подготовки к вызову функции, мы выделили память под 6 элементов, забили память WASM нашими значениями, в таком виде:

Как будут абстрактно выглядеть наши значения в памяти

Как будут абстрактно выглядеть наши значения в памяти

В этом нам как раз и помогает функция модуля — _malloc и setValue: первым мы выделяем память на определенное количество байт, а вторым мы передаем значение по адресу в память WASM, используя определенный LLVM-тип.

Пример
// Выделяем память на n число байт const arrayPtr = module._malloc(vec.length * vec.BYTES_PER_ELEMENT);  // Устанавливаем значения в WASM-память vec.forEach((item, index) => {     module.setValue(arrayPtr + index * vec.BYTES_PER_ELEMENT, item, 'i32'); })    

Вместо вызова _free мы можем добавить в C++ код delete x, где x — переменная, которую мы хотим освободить.

Именно из-за нашей подготовки памяти нам и получилось передать массив через WASM в С++.

Как передавать строки

Передавать строки советую в виде указателей — будет намного меньше проблем и кода.

  1. Сначала мы выделяем память под строку и получаем ее указатель.

  2. Передаем этот указатель в качестве типа указателя «*» с фиксированным размером в 4 байта, что спасёт нас от дополнительной арифметики.

Выглядеть функция будет примерно так:

Пример реализации функции по выделении строки
/**  * Функция для выделения и копирования строки в память WebAssembly  * @param str строка  * @param module модуль wasm  * @return {number} указатель на начало строки  */ export function allocateString(str: string, module: ModuleNative): number {     /**      * Когда мы копируем строку в память WebAssembly с помощью функции module.stringToUTF8, мы должны учитывать, что в конце строки должен быть нулевой символ (нулевой терминатор). Иначе мы будем терять часть символов.      */     const lengthBytes = (module.lengthBytesUTF8(str) + 1);     /**      * Выделяем память под строку      */     const stringPtr = module._malloc(lengthBytes);     /**      *      * Функция module.stringToUTF8 используется для копирования строки из JavaScript в память WebAssembly в формате UTF-8.      * Функция stringToUTF8 предназначена для правильного копирования и кодирования JavaScript-строки в память WebAssembly в формате UTF-8.      */     module.stringToUTF8(str, stringPtr, lengthBytes);     return stringPtr; }

Сначала мы получаем нужное количество байтов для строки, не забываем обязательно про нулевой терминатор — конец строки, иначе будут строки съезжать влево на 1 элемент!

Затем выделяем нужное количество байт и копируем значение в память WASM, получаем ссылку на строку.

Как передавать булево значение

Тут всё просто — один байт с нулем или единицей:

Пример
const boolPtr = module._malloc(1); module.setValue(boolPtr, 0 или 1, 'i8');

Пример со структурами и массивом структур

В нашей ситуации (опять же, про бинды мы не знаем!), нужно самим передавать данные в память и я расскажу, как это сделать.

Пункты действий
  1. Создадим структуру в С++

EXTERN struct Person {     const char* name;     int age; };
  1. Создаем функцию

Person* getTheOldestPerson(Person* persons, int length) {     int maxAge = 0;     int index = 0;     for (int i = 0; i < length; i++) {         if ( persons[i].age >= maxAge ) {             maxAge = persons[i].age;             index = i;         }     }     return &persons[index]; }

Очень важно правильно расставлять поля, потому что при работе с объектами в функциях, память их значений будет указана как при создании структуры. name — сначала идет ссылка на строку в памяти в 4 байт, потом уже age — 4 байта числа, никак иначе!

Итак, чтобы передать массив структур, нам надо…

Мы хотим передать следующий массив объектов в C++:

[     { name: 'Alice', age: 30 },     { name: 'Bob', age: 25 },     { name: 'Charlie', age: 35 },     { name: 'Shakira', age: 20 } ]

Для этого нам нужно сделать следующие шаги:

  1. Сначала выделим для каждой строки память и передадим туда её копию с помощью нашей функции allocateString

  2. Считаем общее количество нужных байтов для массива:
    4 указателя по 4 байта и 4 int32 по 4 байта = 4*(8) = 32 байт нам нужно на этот массив.

  3. Выделяем память и начинаем забивать ее значениями.

На выходе получаем такой JavaScript-код:

Пример вызова функции
// Создать массив объектов const people: People[] = [     { name: 'Alice', age: 30 },     { name: 'Bob', age: 45 },     { name: 'Charlie', age: 35 },     { name: 'Shakira', age: 20 } ];   // Выделить память для массива объектов и копируйте данные const personSize = 8; // Размер структуры Person в байтах (2 поля: name (4 байта) и age (4 байта)) const arrayPtr1 = module._malloc(people.length * personSize);  /**  * Для каждого объекта (в данном случае - для каждой персоны) мы копируем значения из JavaScript в память WebAssembly.  */ people.forEach((person, index) => {     const namePtr = allocateString(person.name, module);     module.setValue(arrayPtr1 + index * personSize, namePtr, '*'); // Указатель на имя     module.setValue(arrayPtr1 + index * personSize + 4, person.age, 'i32'); // Значение возраста });

В итоге, мы заполним память следующим образом:

Как будут абстрактно выглядеть наши значения в памяти

Как будут абстрактно выглядеть наши значения в памяти

Вызываем функцию и получаем указатель на объект, разбираем его:

Получение и сбор результата
const resultPtr = getTheOldestPerson(arrayPtr1, people.length); // Получаем значение указателя на строку из памяти const namePtr = module.getValue(resultPtr, '*'); // Получаем строку из указателя имени const name = module.UTF8ToString(namePtr); // Получаем возраст из указателя результата + 4 из-за указателя на имя const age = module.getValue(resultPtr + 4, 'i32');  const theOldestPerson: People = {     name,     age }  // Освободить выделенную память только из результата module._free(resultPtr); module._free(namePtr);  console.log('Самый старый человек: ', theOldestPerson);

Получаем на выходе: «Самый старый человек: { name: 'Charlie', age: 35 }«, отлично.

Что такое бинды (Emscripten bindings)?

А если я вам скажу, что мы можем убрать всю эту тему с выделением памяти и получения из нее же значений?

Что можно регистрировать вектора и передавать их в функции без проблем?

Давайте разберемся.

Регистрация векторов и функций

Emscripten предлагает создание статических обёрток над С++ кодом, выглядит это так:

Пример

Теперь мы сможем пользоваться нашей функции без «_», и создадим обёртку функции от Emscripten.

module.myFunction(...) — вот такой вызов у нас будет из JavaScript-кода.

Что же до векторов? — Всё просто, допустим, у нас есть функция, которая принимает вектор.

myFunction(std::vector<int> &vec) {...} — Мы не сможем даже используя менеджмент памяти передать вектор, так что нужно тоже воспользоваться биндами:

Регистрация вектора
EMSCRIPTEN_BINDINGS(my_module) {     emscripten::function("myFunction", &myFunction);     emscripten::register_vector<std::vector<int>>('MyVector'); }

Теперь мы можем создать вектор с нашими данными и передать в С++ функцию:

Пример использования
const vector = new module.MyVector(); vector.push_back(1); vector.push_back(2); vector.push_back(3);  module.MyFunction(vector);

Мы передали значение и С++ код его получит и корректно обработает.

А как же нам быть, если наша функция возвращает вектор и мы хотим его корректно получить? — Всё еще проще:

Разбираем результат вектора
// Вызываем функцию const resultVector = module.MyFunction(vector);  // Преобразуем результат обратно в массив JavaScript const outputArray = []; for (let i = 0; i < resultVector.size(); i++) {   outputArray.push(resultVector.get(i)); }  // Освобождаем память, если необходимо vector.delete(); resultVector.delete();  // Вывод результата console.log(outputArray);

Данная обёртка над векторами хорошо работает и проблем с ней не должно возникать.

Финальный пример. Полностью передаем управление Emscripten bindings

Что делать, если мы вот вообще хотим сделать нашу интеграцию максимально удобно? — Использовать emscripten::val!

Функция для этого примера:
EXTERN struct User {     std::string id;     std::string name;     bool isSuperUser; };  EXTERN EMSCRIPTEN_KEEPALIVE emscripten::val createUsers(emscripten::val userArray) {     std::vector<User> result;     const int length = userArray["length"].as<int>();     for (int i = 0; i < length; i++) {         User newUser;         const std::string uuid = generateUUID();         newUser.id = uuid;         newUser.name = userArray[i]["name"].as<std::string>();         newUser.isSuperUser = userArray[i]["isSuperUser"].as<bool>();         result.push_back(newUser); // Создание и добавление элемента в конец вектора     }     return emscripten::val::array(result); }

Очень удобно, мы можем обращаться прямо как с объектами в JavaScript.

Собираем с флагом —bind и используем в модуле:

Пример использования
const users: User[] = [     {name: 'Oleg', isSuperUser: false},     {name: 'Rurik', isSuperUser: true},     {name: 'Alexander', isSuperUser: false} ];  const result = module.createUsers(users); console.log('Новые пользователи:', result);

Запускаем, получаем ошибку от WASM, что нет какого-то 4User.

Смысл в том, что модулю-обёртке нужно помочь указать, что будет передавать в аргументах.

Выглядеть это будет так:
EMSCRIPTEN_BINDINGS(my_module) {     emscripten::function("createUsers", &createUsers);     emscripten::value_object<User>("User")         .field("id", &User::id)         .field("name", &User::name)         .field("isSuperUser", &User::isSuperUser)     ; }

Запускаем заново… получаем хороший результат:
Новые пользователи: [   {     id: 'a2aed6b1-253e-4e7d-b9bd-6e85f6c94eaf',     name: 'Oleg',     isSuperUser: false   },   {     id: 'a581238c-35ba-4183-bdda-7cb3355bcd1a',     name: 'Rurik',     isSuperUser: true   },   {     id: 'fc1c3736-30da-4630-a24d-b33076c6471f',     name: 'Alexander',     isSuperUser: false   } ]

И, как вы могли заметить, мы вообще ничего не сделали, чтобы получить адекватный для JavaScript результат, сразу появился приемлемый массив объектов.

Этот факт, что использование биндов Emscripten делает жизнь разработчика в сотни раз проще, не может не радовать, но, вероятно, придется пожертвовать производительностью при сериализации в val, но это уже отдельный разговор.

Доступ к JavaScript из С++

Emscripten так же позволяет получать доступ к глобальным переменным JavaScript внутри С++ через emscripten::val::global(«переменная»);.

Например, получим доступ к консоли и выведем там массив из нашего последнего примера, если добавим ту функцию следующую строку:Таким же образом можно получить доступ к document в браузере, что как раз кстати, да и к любой глобальной переменной.

emscripten::val::global(«console»).call<void>(«log», userArray);

После запуска функции в JavaScript, у нас в консоли появится:

Вывод консоли
[   { name: 'Oleg', isSuperUser: false },   { name: 'Rurik', isSuperUser: true },   { name: 'Alexander', isSuperUser: false } ]

Таким же образом можно получить доступ к document в браузере, что как раз кстати для работы с DOM, да и к любой глобальной переменной.

Крайне рекомендую всё же установить IDE с линтером и ознакомиться с функционалом.

Вывод

  • Мы создали несколько примеров, прояснили, как наладить общение между С++ и JavaScript через WASM.

  • Мы познакомились с выделением, копированием в память элементов и получению данных из памяти.

  • Узнали про бинды Emscripten, который очень сильно упрощают разработку.

Благодарю всех за внимание, надеюсь, что вам понравилась статья и работа с WASM будет более понятной.

Код для данной статьи и докерфайл с этапом сборки C++ и запуском примеров вы можете скачать и посмотреть на гитхабе.


ссылка на оригинал статьи https://habr.com/ru/articles/837692/