Linux из Java без JNI: от FFM и /dev/kvm до интерактивной консоли

Да, направление здесь немного непривычное: обычно Java запускают в Linux, а здесь я решил попробовать запустить Linux из Java.
Причём не через QEMU, не через готовую обёртку над каким‑нибудь hypervisor API и без собственной JNI‑библиотеки. Host‑приложение написано на Java 25, с Linux KVM оно общается через Foreign Function & Memory API.
И в какой‑то момент всё это действительно дошло до такого:
/ # ls
bin dev init proc root sys
/ #То есть внутри поднялся настоящий Linux kernel, дошёл до BusyBox userspace, появился интерактивный shell, в него можно было ввести команду и получить ответ.
Именно в этот момент я впервые подумал: «Что, реально? Оно работает?»
Но начиналось всё вообще не с виртуализации.
Стоп, а зачем Java ещё один API для native‑вызовов?
Я в основном слежу за LTS‑релизами Java. Долгое время в голове актуальной стабильной версией была Java 21, а потом я решил посмотреть, что успело появиться дальше и что вообще интересного принёс JDK 25.
Читал changelog и всё шло стандартно: где‑то расширили синтаксис, где‑то появились новые API, улучшили производительность JVM... И тут увидел Foreign Function & Memory API. Первая реакция была примерно такой:
Стоп. Но ведь для вызова native‑кода уже существует JNI. Зачем в JDK понадобился ещё один механизм?
FFM к моменту Java 25 уже давно перестал быть экспериментальной фичей (ещё со времен Java 22), но я нормально столкнулся с ним именно тогда.
Когда изучаю что‑то новое, мне проще делать это через небольшой проект. Теорию можно прочитать и вроде бы всё понять, но именно в реализации обычно всплывают детали, которые документация либо закономерно опускает, либо ты сам понял слишком поверхностно.
Почти одновременно я разгребал старые starred‑репозитории GitHub и обнаружил, что года три назад сохранил себе kvm‑hello‑world. Это совсем небольшой пример работы с KVM, буквально на уровне «создать VM и выполнить внутри несколько байт машинного кода».
До этого виртуализация для меня выглядела скорее как магия сложных систем по типу QEMU, VMware и Hyper‑V. А здесь внезапно оказалось, что сам механизм можно буквально потрогать руками:
open("/dev/kvm")
↓
ioctl(...)
↓
создать VM
↓
создать vCPU
↓
настроить guest memory
↓
KVM_RUNИ всё как‑то сошлось само собой. С одной стороны новый для меня FFM. С другой KVM как достаточно серьёзная native‑задача.
Только повторять kvm-hello-world один в один мне не хотелось. Если уж разбираться, то со всеми подробностями. Хотелось дойти до минимальной, но настоящей VM, которая способна загрузить Linux и дать возможность взаимодействовать с ним через терминал.
И довольно быстро выяснилось, что между «KVM выполнил мои байты» и «у меня есть Linux VM» лежит несколько очень разных этапов.

Каждый следующий пункт заставлял реализовать кусок системы, который предыдущий demo просто не затрагивал.
Native‑слой оказался маленьким. Ответственность — нет
Собственно libc boundary у проекта довольно небольшой. В основном PanamaKVM нужны:
openioctlmmapmunmapclose
FFM позволяет описать эти вызовы прямо в Java. Если очень грубо:

С JNI пришлось бы поддерживать ещё и собственный C glue и его сборку. Здесь файловые дескрипторы, native представление данных, владение памятью и сами вызовы остаются в Java‑коде.
Удобно. Но быстро выяснилось, что из «описано на Java» не следует «Java гарантирует правильность описания». Лучше всего это показал один из первых багов проекта.
KVM_GET_API_VERSION и загадочный EINVAL
В какой‑то момент совершенно базовый вызов KVM_GET_API_VERSION возвращал EINVAL. Особенно неприятно было то, что почти всё вокруг выглядело нормально. /dev/kvm существовал. Права я проверил. Модули KVM были загружены. Константа запроса из документации тоже совпадала с ожидаемым.
Причём буквально перед контрольным запуском я менял permissions для своей учётки в WSL, поэтому первая гипотеза была максимально приземлённой: наверное, текущая shell‑сессия ещё не подхватила изменения. Перезапустил терминалы — не помогло.
Дальше привычная реакция Java разработчика — подключаем дебаггер. Хоть он и помог немного сузить место ошибки, но довольно быстро возник другой вопрос: если интересующая часть приложения сейчас в основном состоит из native вызовов, зачем пытаться наблюдать только Java сторону? Можно посмотреть на реальные syscalls.
Так в расследовании появилось место для strace.
И пространство гипотез резко уменьшилось: до kernel действительно доходил KVM_GET_API_VERSION, только вместе с ним прилетал неожиданный третий argument.
С этого момента уже пришлось нормально разбираться, откуда он вообще взялся.
С точки зрения KVM API запрос выглядит как операция без параметров. Но libc‑функция ioctl variadic:
int ioctl(int fd, unsigned long request, ...);А в использованном Linux/glibc path wrapper извлекает variadic argument и передаёт его дальше системному вызову:
va_start(args, request);
void *arg = va_arg(args, void *);
...
ioctl(fd, request, arg);Мой первоначальный FFM descriptor описывал только аргументы, которые с точки зрения операции казались содержательно значимыми: fd и request. Но native‑вызов выполняется не по моей интерпретации документации, а по конкретному ABI.
Так, третий слот оказался неопределенным.
При этом внутри KVM обработчик KVM_GET_API_VERSION проверяет arg: если значение ненулевое, возвращается -EINVAL.
Исправление оказалось почти издевательски маленьким: добавить третий argument в descriptor и для отсутствия payload вызова явно передавать 0L. Но сам баг оказался гораздо полезнее размера исправления.
В FFM FunctionDescriptor — это фактически исполняемое утверждение об ABI:
Я считаю, что native‑функцию нужно вызвать именно так.
FFM может избавить меня от JNI glue. Он может дать нормальную модель lifetime и memory access. Но JVM не способна по документации KVM догадаться, что мой ABI contract составлен неправильно.
И ещё один вывод из этой истории я себе оставил: если native error допускает десять одинаково правдоподобных объяснений, иногда полезнее не придумывать одиннадцатое, а посмотреть на фактическую границу процесса.
Хорошо, KVM отвечает. Как теперь загрузить Linux?
Создать VM и выполнить заранее подготовленный guest скрипт — всё ещё довольно далеко от Linux. Я почти сразу полез в Linux x86 boot protocol.
И тут выясняется очевидная задним числом вещь: нельзя просто скопировать kernel в RAM, выставить
RIP = kernelAddressи ждать загрузки.
Ядро ожидает проснуться в определённом мире, и этот мир кто‑то должен для него подготовить.
В моём случае пришлось как минимум:
проверить setup header
bzImageподготовить
boot_paramsописать guest physical memory через E820
разместить kernel command line
загрузить protected‑mode kernel payload
положить в память initramfs
подготовить GDT и CPU
выставить точку входа
передать указатель на
boot_paramsчерезRSI
Упрощённая карта памяти получается примерно такой:

Для отдельной статьи про boot protocol здесь материала намного больше: setup header, ограничения адресов, E820, selectors, placement initramfs и так далее. Для текущей истории важнее другое. PanamaKVM реализует Linux x86 boot protocol.
Важно. Это не то же самое, что «PanamaKVM уже поддерживает все Linux‑дистрибутивы». Сегодня реально проверен конкретный Alpine 3.24.2 x86-64 с подготовленным initramfs. Другой kernel может использовать тот же boot protocol, но userspace, modules, root filesystem и command line всё равно могут вести себя иначе.
Настоящий kernel быстро нашёл ещё один отсутствующий кусок
Сэмулированный guest использует только те состояния, которые ты сам в него заложил. Linux такой вежливостью не отличается.
Во время первой нормальной загрузки KVM вернул KVM_EXIT_MMIO. Event loop виртуальной машины этого состояния просто не понимал. Самый быстрый путь был бы добавить очередное:
if (exitReason == 6) {
...
}Но к этому моменту мне уже совсем не хотелось протаскивать KVM_EXIT_*, offsets из kvm_run и остальные детали UAPI по всему приложению.
Поэтому KVM‑specific состояние переводится на boundary в типизированную семантику:

KVM_EXIT_MMIO: от выхода из KVM_RUN до MMIO bus и обработчика устройства То же самое относится к port I/O, halt, shutdown и другим exits. И всё это именно потому, что числовые коды KVM API должны заканчиваться там, где заканчивается KVM backend.
Как мне кажется, это хороший пример того, как уже реальный guest с запущенным ядром менял неподготовленную архитектуру. Linux начал предъявлять состояния, которые симуляция не описывала.
Первый настоящий userspace
Следующим важным результатом стала всего одна строка:
PANAMAKVM_SMOKE_OKЕё уже печатал BusyBox /init после загрузки настоящего Linux kernel. Там же можно было выполнить uname, получить информацию о памяти и после этого детерминированно завершить smoke workload.
Для тестов это отличный guest: загрузился, доказал достижение userspace, напечатал известный маркер, завершился. После этого уже можно было говорить о полноценно запущенном Linux ядре внутри PanamaKVM до BusyBox userspace.
Но лично меня такой результат всё ещё не удовлетворял. Пользователь ведь никак не взаимодействует с системой. Я заранее написал /init, запустил его и посмотрел на заранее ожидаемый результат. По ощущениям это всё ещё ближе к запуску программы, чем к VM. Поэтому следующим этапом стало добавление интерактивности.
Когда VM наконец начала отвечать
Наивная версия выглядела очень просто: UART уже выводит kernel log. Значит, добавляем ввод в обратную сторону и получаем shell.
На практике между «serial output работает» и «терминалом удобно пользоваться» обнаружилось довольно много подводных камней: UART RX, прерывания, host terminal mode, управление TTY внутри guest, echo, canonical input.
Но в какой‑то момент — после долгой отладки — прошел boot log и появился:
/ #Я ввел:
lsи получил:
bin dev init proc root sysВот это уже был катарсис. По настоящему качественный переход между работой Linux и тем фактом, что теперь можно с ним разговаривать.

Причём именно терминал потом подарил, пожалуй, самое интересное расследование всего проекта. Обычный Ctrl+C пришлось протащить и проверить через: host TTY, Java, UART, IRQ, guest TTY, foreground process group, SIGINT disposition. В какой‑то момент 0x03 уже гарантированно доходил до guest, IRQ внутри Linux рос, foreground process group был правильным, а sleep 30 всё равно не завершался. Причина оказалась уже не в KVM и даже не в UART.
Но эту историю я оставлю для отдельной статьи: она получилась достаточно большой, чтобы не превращать текущий материал в каталог всех багов проекта.
Что получилось — и чего пока нет
Если спросите, что из себя представляет PanamaKVM, то я вкратце расскажу так:
Это небольшой Java VMM, который через FFM работает с Linux KVM, реализует direct boot x86-64 Linux
bzImageи предоставляет минимальную виртуальную платформу, достаточную для загрузки проверенного Alpine guest и работы через serial console.
Сейчас нет полноценного block device, network device, SMP, firmware/UEFI и production hardening для hostile guest workloads. И существующие функциональные времена запуска я не считаю benchmark'ами. Для performance claims нужен отдельный нормальный эксперимент.
Это осознанные границы проекта.
Но ведь уже существуют QEMU и Firecracker
Если задача: просто надёжно запускать production workloads, то PanamaKVM действительно не нужен. Я сам взял бы зрелый инструмент.
Ценность проекта для меня в другом: он остаётся достаточно маленьким, чтобы можно было практически проследить весь слой интеграции между Java вызовами и тем, как работает KVM, его native функции и Linux.
При этом внутри нет игрушечной симуляции ключевых слоёв. Здесь настоящие: native вызовы, Linux KVM, x86 boot protocol, kernel, VM exits, минимальные виртуальные устройства, userspace. И система всё ещё достаточно мала, чтобы в ней можно было понять причинно‑следственную связь, а не просто увидеть результат работы enterprise системы виртуализации.
Именно поэтому я не хочу постепенно превращать проект в Java‑QEMU. После первого работающего Linux очень легко написать roadmap, а через год получить большой незаконченный VMM. Следующая функция имеет смысл, если она позволяет проверить новый конкретный workload или инженерную гипотезу. Если устройство добавляется только потому, что «у нормальных VM такое есть», возможно, оно вообще не нужно этому проекту.
Так что в итоге дал FFM?
Начинал я с вопроса:
Зачем Java ещё один native API, если уже существует JNI?
После PanamaKVM мой ответ выглядит примерно так. FFM действительно сильно удешевляет небольшой native boundary. Не нужно писать C библиотеку только ради нескольких open/ioctl/mmap/close. Native layouts и lifetime можно держать рядом с Java‑кодом. Вокруг низкоуровневого слоя остаются обычные records, sealed types, JUnit и остальная JVM‑экосистема. Но системное программирование от наличия такого API никуда не исчезает.
FFM не знает:
правильно ли описан ABI;
соответствуют ли offsets реальной kernel structure;
что именно означает
ioctl;какую память ожидает Linux при boot;
как должен вести себя UART;
почему
Ctrl+Cпревращается в signal.
Java позволила мне не покидать привычную экосистему. Но после пересечения JVM boundary пришлось разбираться уже далеко не только в Java.
Собственно, ради этого эксперимент и начинался.
PanamaKVM: https://github.com/rusanoph/panama‑kvm/tree/v0.1.0
Отдельно хочу разобрать ещё два сюжета:
путь одного
Ctrl+Cчерез host terminal, Java, UART, Linux TTY и Unix signals;Linux x86 boot protocol: что именно должно оказаться в памяти и регистрах до первой инструкции ядра.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.