Введение

Всем привет. Сегодня я хочу поговорить об использовании 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 в С++.

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

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

  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_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)


  1. 19Zb84
    21.08.2024 23:21

    const WasmModule = require(path.join(__dirname, '..', 'build', 'native.example.js'));

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


  1. codecity
    21.08.2024 23:21

    Как ни крути, а очень важна возможность использовать либы на всех платформах. У меня для C++ есть готовые проверенные CMake -файлы, чтобы собирать код сразу под: Windows, Linux, MacOS, Android, iOS и WASM. Сразу 6 платформ, к которым пришло человечество на текущий момент существования. И будет работать везде.

    Чем-то аналогичным может похвастаться не так много ЯП. Голый C слишком не удобен, все равно там колхозят ООП на основе универсального указателя void*, что весьма неудобно. Rust вроде бы хорош, во многом прогрессивнее C++ - но не имеет удобного и привычного всем ООП-стиля, а то что имеет - не так удобно. Т.е. пока преимущества Rust не покрывают его недостатки.


    1. loltrol
      21.08.2024 23:21

      У rust во много раз круче ооп, чем в cpp, но там другая проблема - программист по умолчанию макака, и это забота программиста доказать компилятору обратное.


      1. codecity
        21.08.2024 23:21

        Ну что значит "круче"? В смысле сложнее что-либо там реализовать и сложнее разобраться и если ты смог написать ООП на Rust - то ты крут среди пацанов? Ну может разве что в таком смысле. А так давайте на конкретно на примерах посмотрим:

        1. Пример на CPP: https://www.programiz.com/online-compiler/1FWx2dzzP9GFe

        2. То же самое на Rust: https://www.programiz.com/online-compiler/52EHKtXHVfGnO

        В Rust даже на таком маленьком примере - куча бойлерплейт-кода. Понятно что и на голом C можно писать ООП, но там бойлерплейта еще больше.

        ООП нужен, он используется, его используют. Во всех популярных языках - Java, C#, C++, TypeScript, отчасти JS, Dart - ООП есть и реализован примерно схожим образом. Это классические инкапсуляция, наследование, полиморфизм.

        Вы можете полюбить Rust и как бы себя убеждать - ну нет, давайте условно примем что в нем хотя и нет уровней доступа, нет удобного наследования - убедим себя что это кручено. Нет, не круче.


  1. UranusExplorer
    21.08.2024 23:21

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

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

    OMG. Зачем так? Что мешает передать bool как i32 с нулем или единицей, зато без дополнительных аллокаций?

    Создадим структуру в С++

    Я подозреваю что в зависимости от размеров полей компилятор может применить паддинг (выравнивание), и между полями структуры могут оказаться "дырки" без значений. Это нужно учитывать при заполнении структуры со стороны JS, либо использовать всякие pragma чтобы объявить структуру как packed.


    1. Avangardio Автор
      21.08.2024 23:21

      Что мешает передать bool как i32 с нулем или единицей, зато без дополнительных аллокаций?

      Привет, потому что нужно было показать саму возможность такого типа.

      между полями структуры могут оказаться "дырки" без значений

      Делал много примеров перед статьей и с таким не сталкивался ни разу и сказать не могу


      1. UranusExplorer
        21.08.2024 23:21

        Вот тут подробно расписано: http://www.catb.org/esr/structure-packing/

        Если компилятор выравнивает по четырем байтам (учитывая что в wasm минимальный тип 32-битный), то есть вероятность что паддинг может произойти даже от структуры типа { char, int } или { short, int } там после первого поля будет дырка в три или два байта размером. А может и не будет, зависит от того выравнивает ли компилятор данные для этого конкретного таргета.


        1. codecity
          21.08.2024 23:21

          А может и не будет, зависит от того выравнивает ли компилятор данные для этого конкретного таргета

          Обычно сложные типы передают туда-сюда как JSON, если нет особых требований по скорости. Или как массив байт типа uint8_t, там тоже дырок не будет.

          А так да, согласен - даже если работает сейчас - не факт что будет работать потом.