PunchFG to raise Niger power plant output to 700MWThe Jerusalem PostHamas used double agents, deception to fool Israel ahead of October 7 massacre, study findsRTP DesportoAlbuquerque diz que Cristiano Ronaldo não pode ser tratado como um qualquerESPNTop scenes from Rockets, Mavs trip to Macao, China, for preseason slateESPN DeportesJoya del Arsenal recibe tres récords GuinnessBollywood HungamaPrasanna Bisht starrer Bokshi release postponed to December 4 after CBFC review over witchcraft and gruesome violenceInquirer2 wounded in roadside shooting in LaoagZDF heuteAktuelle Pressemitteilungen des ZDFStraits Times SportInfantino promises further FIFA support for African footballThe South AfricanOrlando Pirates vs Mamelodi Sundowns: Road to the MTN8 finalTechCrunchGoogle releases a new local-first Granola competitorLa TerceraPor segundo año consecutivo dos listas de izquierda pasan al balotaje en las elecciones de la FEUC
The Daily Newsstand · Free, Always
Thursday, October 8, 2026

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

Translate

Да, направление здесь немного непривычное: обычно 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» лежит несколько очень разных этапов.

Путь от запуска guest-кода через KVM до консоли

Путь от запуска guest‑кода через KVM до консоли

Каждый следующий пункт заставлял реализовать кусок системы, который предыдущий demo просто не затрагивал.

Native‑слой оказался маленьким. Ответственность — нет

Собственно libc boundary у проекта довольно небольшой. В основном PanamaKVM нужны:

  • open

  • ioctl

  • mmap

  • munmap

  • close

FFM позволяет описать эти вызовы прямо в Java. Если очень грубо:

Как PanamaKVM добирается из Java до KVM API без JNI

Как PanamaKVM добирается из Java до KVM API без JNI

С 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

Упрощённая карта памяти получается примерно такой:

Упрощённая раскладка гостевой физической памяти при загрузке Linux

Упрощённая раскладка гостевой физической памяти при загрузке Linux

Для отдельной статьи про 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 и обработчика устройства

Обработка 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: что именно должно оказаться в памяти и регистрах до первой инструкции ядра.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.