FFI: вызов готовой разделяемой библиотеки из JS через koffi — и кто владеет памятью
FFI вызывает уже скомпилированную разделяемую библиотеку из JS — без C-обвязки и компилятора — объявляя C-прототип и позволяя libffi построить вызов в рантайме. Нативную память GC не видит, поэтому неверная сигнатура или неосвобождённый указатель роняет процесс.
Команде понадобились три функции из вендорского C SDK — .so без JavaScript-биндингов, — и никто не хотел тащить на себе C++-аддон. Взяли ffi-napi: объявили сигнатуры функций в JS, указали на библиотеку, готово за полдня. На Node 16 работало. Потом прилетело обновление Node, и npm install умер, пересобирая нативный бинарь ref-napi под новый ABI — стек FFI сам является скомпилированным аддоном. Запинили Node и поехали дальше. Через месяцы пришёл настоящий счёт: одна сигнатура объявила 64-битный хэндл устройства как 'int', поэтому каждый возвращаемый указатель тихо обрезался до 32 бит. Чаще всего старшие биты были нулями, и всё работало. Под нагрузкой, когда куча легла выше в адресном пространстве, — нет, и процесс ушёл в SIGSEGV прямо в пол, без стектрейса, без catch, посреди запроса. Фикс был не «писать ещё C». Он был в понимании того, что FFI делает на самом деле: объявить точный ABI, решить, кто что освобождает, и перестать гонять незаложенный биндинг.
Что такое FFI — и чем он отличается от нативного аддона
За десять минут ты поймёшь, почему SIGSEGV из хука случился именно там, как FFI работает на уровне ABI, и три правила владения памятью, отделяющие безопасную интеграцию от бомбы замедленного действия.
Прошлые уроки строили нативный аддон: ты пишешь C/C++-обвязку под Node-API, компилируешь её в .node-бинарь и делаешь require. FFI (Foreign Function Interface, интерфейс вызова внешних функций) обходится без обвязки вовсе. Ты берёшь уже скомпилированную разделяемую библиотеку — libfoo.so на Linux, .dylib на macOS, .dll на Windows, — объявляешь прототип нужной функции на JavaScript, и библиотека маршалит вызов в рантайме. Ни своего C-кода, ни компилятора на установке, ни .node для сборки: просто открой библиотеку и опиши сигнатуру.
import koffi from 'koffi';
const libm = koffi.load('libm.so.6'); // dlopen разделяемой библиотеки
const pow = libm.func('double pow(double, double)'); // объявляем C-прототип
pow(2, 10); // 1024 — libffi пакует double'ы по ABI, вызывает, читает результатТа строка libm.func('double pow(double, double)') — и есть вся идея: строка, которая и есть C-сигнатура. Нет компилятора, сверяющего её с настоящим pow из libm. Объявление — контракт, который обеспечиваешь только ты, и это центральный размен против нативного аддона, где C-компилятор типизирует твою обвязку. FFI покупает тебе «никакого тулчейна, обернуть существующую библиотеку за минуты» ценой «ошибёшься в одном типе — и сказать об этом будет некому».
libffi: построение кадра вызова в рантайме
Под каждым FFI-биндингом сидит libffi (библиотека, динамически строящая кадры вызовов по соглашениям платформенного ABI). У обычного скомпилированного вызова раскладка аргументов вшита на этапе компиляции — компилятор знает положить первое целое в rdi, первый float в xmm0 и так далее по соглашению о вызовах платформы (System V AMD64 на Linux/macOS, Microsoft x64 ABI на Windows). У FFI такой роскоши нет: он узнаёт сигнатуру лишь в рантайме, из твоего объявления. Поэтому libffi строит кадр вызова динамически — читает объявленные типы, кладёт каждый аргумент в нужный регистр или слот стека под ABI, прыгает через трамплин в функцию библиотеки и читает возвращаемое значение из регистра возврата.
Цена — этот динамический шаг. Каждый FFI-вызов платит трамплин плюс помаршаливание каждого аргумента — порядка десятков наносекунд поверх собственной работы C-функции. Для горстки крупных вызовов на запрос это незаметно. Для горячего цикла с миллионами мелких вызовов это доминирует, и скомпилированный Node-API-аддон — чьи вызовы прямые, без построения кадра в рантайме — выигрывает решительно. Ниша FFI — крупнозернистые вызовы в существующую библиотеку, а не болтливый внутренний цикл.
koffi против ffi-napi: бери поддерживаемый
В Node две линии FFI, и выбор в 2025-м неравный.
Классический стек — ffi-napi + ref-napi — потомки node-ffi. Его фатальное свойство — то самое из хука: он сам нативный аддон. Установка компилирует C, так что ты наследуешь всю матрицу node-gyp/prebuild/ABI из прошлого урока — ровно ту боль сборки, которую FFI должен был устранить, — плюс поломки на каждом сдвиге ABI в мажоре Node. Сейчас он практически не поддерживается. Не начинай на нём новую работу.
koffi — современный ответ. Он поставляет prebuilt-бинари под распространённые платформы (никакого node-gyp на установке), активно поддерживается, работает заметно быстрее и имеет первоклассную поддержку структур, указателей, выходных параметров, колбэков и disposable-типов. Для новой FFI-работы koffi — senior-дефолт; относись к ffi-napi как к легаси, с которого мигрируют.
Владение памятью: граница, которую GC не пересекает
Здесь FFI кусается, и это стоит сформулировать жёстким правилом: сборщик мусора JavaScript не видит нативную память, а C не видит JS-кучу. Это два отдельных мира без общего учёта. Три следствия, которыми управляешь руками:
- C-аллокации освобождаешь ты. Если функция возвращает
malloc-нутый указатель (char*-строку, структуру), GC никогда его не вернёт — нужно самому вызвать парную функцию освобождения библиотеки. Забудешь — получишь классическую утечку C-памяти, в Node-процессе, которую не объяснит ни один heap snapshot, потому что она вне кучи. - Буферы, что ты передаёшь, должны пережить вызов. Когда ты отдаёшь C указатель в JS
Bufferили типизированный массив, эта память безопасна лишь пока JS-объект жив и не сдвинут. Если C хранит указатель и использует его после возврата (async-колбэк, удержанный хэндл), нужно держать живую JS-ссылку, чтобы GC не собрал и не переместил буфер — иначе C разыменует освобождённую память. - Выходным параметрам нужна явная аллокация. Многие C-функции заполняют переданный вызывающим буфер (
int read(fd, void* buf, size_t n)). Ты аллоцируешь его (koffi.alloc), передаёшь указатель, затем декодируешь, что C записал.
// C: char* make_greeting(const char* name); // возвращает malloc-нутую строку
// void free_greeting(char* p); // вызывающий обязан освободить
const make = lib.func('char* make_greeting(const char*)');
const free = lib.func('void free_greeting(char*)');
const ptr = make('Ada'); // C это malloc-нул — GC не в курсе
const text = koffi.decode(ptr, 'char*'); // копируем байты в JS-строку
free(ptr); // освобождаешь ТЫ, иначе течёт вечно▸Почему это работает
Почему это куда острее обычного Node-бага? Потому что сбой — не JavaScript-исключение. Разыменование освобождённого или сдвинутого указателя — это SIGSEGV: ОС убивает процесс. Нет try/catch, который спасёт, нет стектрейса на виноватую строку, и крах часто всплывает далеко от настоящего бага — когда освобождённую страницу следующий раз тронут. Невычитанный стрим течёт мягко; неуправляемый FFI-указатель роняет весь сервер без единого слова. Дисциплина ровно как в C: на каждую аллокацию — освобождение, и держи под ссылкой всё, что нативная сторона ещё удерживает.
Структуры, колбэки и цена неверного типа
Ещё три места, где контракт обязан быть точным:
Структуры. Ты объявляешь раскладку (koffi.struct({ x: 'int', y: 'double' })), и порядок полей, типы и выравнивание/паддинг должны совпадать с C-определением байт в байт. Ошибёшься в паддинге — и каждое поле после него читается с неверного смещения: мусор, не крах, что отлаживать хуже.
Колбэки. Чтобы дать C вызвать обратно в JS (компаратор, обработчик события), ты регистрируешь JS-функцию как C-указатель на функцию. Две опасности: JS-функция должна оставаться живой, пока C держит указатель (потеряешь ссылку — GC соберёт её под ногами у C), и если библиотека вызывает твой колбэк на не-JS-потоке, заход в V8 оттуда роняет процесс — нужна async/registered-обработка колбэков koffi, а не сырой синхронный.
Неверные скалярные типы. Баг из хука обобщается: объявишь 'int' там, где C возвращает 64-битный size_t, long или указатель, — значение обрезается; объявишь знаковый тип для беззнакового — большие значения переворачиваются в отрицательные. Ничего из этого не проверяется. Объявление — единственный источник истины, и его правильность на тебе.
Вендор поставляет закрытую нативную библиотеку (libvendor.so) с тремя функциями, которые надо вызывать несколько раз на запрос из Node-сервиса. Ни исходников, ни JS-биндингов. Какой подход?
Почему koffi FFI не нужен C-компилятор на установке, а Node-API-аддону нужен?
C-функция возвращает malloc-нутый char*, который ты читаешь в JS-строку. В чём риск и как обращаться правильно?
- 01Как FFI вызывает нативный код без компилятора и чем он жертвует против Node-API-аддона?
- 02Сформулируй правила владения памятью при пересечении границы FFI и почему ошибка тут уникально опасна.
FFI позволяет Node вызывать уже скомпилированную разделяемую библиотеку — вендорскую .so, системную библиотеку — прямо из JavaScript, без своей C-обвязки и без компилятора на установке: ты делаешь dlopen библиотеки и объявляешь C-прототип функции, а биндинг строит вызов в рантайме. Под капотом libffi читает объявленные типы и динамически конструирует кадр вызова, кладя аргументы по ABI платформы, прыгая трамплином в функцию и маршаля возврат — поэтому каждый вызов стоит чуть дороже прямого вызова скомпилированного аддона и поэтому FFI подходит крупным вызовам, а не болтливому горячему циклу. Выбор биндинга важен: классический стек ffi-napi/ref-napi сам является нативным аддоном, поэтому тащит всю проблему node-gyp/prebuild/поломок-ABI и ныне практически не поддерживается, тогда как koffi поставляется prebuilt, поддерживается, работает быстрее и обрабатывает структуры, указатели, колбэки и disposables — поэтому koffi и есть senior-дефолт. Настоящая дисциплина — память: GC не видит, что аллоцирует C, а C не видит JS-кучу, поэтому ты освобождаешь каждый указатель, что библиотека malloc-нула, держишь любой переданный Buffer живым (и несдвинутым), пока C его удерживает, аллоцируешь выходные буферы явно и объявляешь каждый тип точно — потому что неверная сигнатура или неуправляемый указатель это не ловимая JavaScript-ошибка, а SIGSEGV, роняющий весь процесс без стектрейса. FFI — самый быстрый способ обернуть существующую нативную библиотеку и самый лёгкий способ уронить сервер, если забыть, что ты теперь пишешь C. Теперь, когда потянешься к FFI для вендорского SDK, сверь три вещи первым делом: точна ли сигнатура (особенно ширина указателей), кто освобождает возвращаемую память, и не бывает ли колбэк не с JS-потока — именно там и ждёт SIGSEGV.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.