Введение
Всем привет. Сегодня я хочу поговорить об использовании 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 коде.
Чтобы создать модуль и использовать его, можно использовать следующий код:
Скрытый текст
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-файла и его обёртки.
Скрытый текст
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 в С++.
Как передавать строки
Передавать строки советую в виде указателей - будет намного меньше проблем и кода.
Сначала мы выделяем память под строку и получаем ее указатель.
Передаем этот указатель в качестве типа указателя "*" с фиксированным размером в 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');
Пример со структурами и массивом структур
В нашей ситуации (опять же, про бинды мы не знаем!), нужно самим передавать данные в память и я расскажу, как это сделать.
Скрытый текст
Создадим структуру в С++
EXTERN struct Person {
const char* name;
int age;
};
Создаем функцию
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 }
]
Для этого нам нужно сделать следующие шаги:
Сначала выделим для каждой строки память и передадим туда её копию с помощью нашей функции allocateString
Считаем общее количество нужных байтов для массива:
4 указателя по 4 байта и 4 int32 по 4 байта = 4*(8) = 32 байт нам нужно на этот массив.Выделяем память и начинаем забивать ее значениями.
На выходе получаем такой 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_BINDINGS(my_module) {
emscripten::function("Конечное название функции", &функция);
}
Теперь мы сможем пользоваться нашей функции без "_", и создадим обёртку функции от 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++ и запуском примеров вы можете скачать и посмотреть на гитхабе.
Комментарии (8)
codecity
21.08.2024 23:21Как ни крути, а очень важна возможность использовать либы на всех платформах. У меня для C++ есть готовые проверенные CMake -файлы, чтобы собирать код сразу под: Windows, Linux, MacOS, Android, iOS и WASM. Сразу 6 платформ, к которым пришло человечество на текущий момент существования. И будет работать везде.
Чем-то аналогичным может похвастаться не так много ЯП. Голый C слишком не удобен, все равно там колхозят ООП на основе универсального указателя void*, что весьма неудобно. Rust вроде бы хорош, во многом прогрессивнее C++ - но не имеет удобного и привычного всем ООП-стиля, а то что имеет - не так удобно. Т.е. пока преимущества Rust не покрывают его недостатки.
loltrol
21.08.2024 23:21У rust во много раз круче ооп, чем в cpp, но там другая проблема - программист по умолчанию макака, и это забота программиста доказать компилятору обратное.
codecity
21.08.2024 23:21Ну что значит "круче"? В смысле сложнее что-либо там реализовать и сложнее разобраться и если ты смог написать ООП на Rust - то ты крут среди пацанов? Ну может разве что в таком смысле. А так давайте на конкретно на примерах посмотрим:
Пример на CPP: https://www.programiz.com/online-compiler/1FWx2dzzP9GFe
То же самое на Rust: https://www.programiz.com/online-compiler/52EHKtXHVfGnO
В Rust даже на таком маленьком примере - куча бойлерплейт-кода. Понятно что и на голом C можно писать ООП, но там бойлерплейта еще больше.
ООП нужен, он используется, его используют. Во всех популярных языках - Java, C#, C++, TypeScript, отчасти JS, Dart - ООП есть и реализован примерно схожим образом. Это классические инкапсуляция, наследование, полиморфизм.
Вы можете полюбить Rust и как бы себя убеждать - ну нет, давайте условно примем что в нем хотя и нет уровней доступа, нет удобного наследования - убедим себя что это кручено. Нет, не круче.
UranusExplorer
21.08.2024 23:21Как передавать булево значение
Тут всё просто - один байт с нулем или единицей:
OMG. Зачем так? Что мешает передать bool как i32 с нулем или единицей, зато без дополнительных аллокаций?
Создадим структуру в С++
Я подозреваю что в зависимости от размеров полей компилятор может применить паддинг (выравнивание), и между полями структуры могут оказаться "дырки" без значений. Это нужно учитывать при заполнении структуры со стороны JS, либо использовать всякие pragma чтобы объявить структуру как packed.
Avangardio Автор
21.08.2024 23:21Что мешает передать bool как i32 с нулем или единицей, зато без дополнительных аллокаций?
Привет, потому что нужно было показать саму возможность такого типа.
между полями структуры могут оказаться "дырки" без значений
Делал много примеров перед статьей и с таким не сталкивался ни разу и сказать не могу
UranusExplorer
21.08.2024 23:21Вот тут подробно расписано: http://www.catb.org/esr/structure-packing/
Если компилятор выравнивает по четырем байтам (учитывая что в wasm минимальный тип 32-битный), то есть вероятность что паддинг может произойти даже от структуры типа { char, int } или { short, int } там после первого поля будет дырка в три или два байта размером. А может и не будет, зависит от того выравнивает ли компилятор данные для этого конкретного таргета.
codecity
21.08.2024 23:21А может и не будет, зависит от того выравнивает ли компилятор данные для этого конкретного таргета
Обычно сложные типы передают туда-сюда как JSON, если нет особых требований по скорости. Или как массив байт типа uint8_t, там тоже дырок не будет.
А так да, согласен - даже если работает сейчас - не факт что будет работать потом.
19Zb84
Лучше сразу преобразование кода в модули делать. Потом меньше проблем с подключением будет.