ООП и Java-синтаксис в 2 КБ ОЗУ: как я пишу компилятор для 8-битных МК без виртуальной машины
В эмбеддед-разработке для 8-битных микроконтроллеров исторически правит бал Си и изредка ассемблер. А что, если писать прошивки на языке с Java-подобным синтаксисом, убрать целый класс багов, знакомых каждому Си-разработчику — утечки памяти, выход за границы массива, молчаливые зависания, — и при этом укладываться в те же спартанские 2 КБ ОЗУ?
Я назвал этот язык J8B (Java для 8-бит)
Первая реакция любого практикующего инженера: «Это бред! Под Java нужна виртуальная машина, она сожрет все ресурсы, ничего серьезного из этого не выйдет».
И вы будете абсолютно правы… если мы говорим о классическом подходе. Но я предлагаю взглянуть на задачу иначе. Я начинаю цикл статей о своем проекте vm5277 — тулките для разработки прошивок под микроконтроллеры, которая не имеет ничего общего с Си, и транслирует объектно-ориентированный код напрямую в нативный, оптимизированный ассемблер.
Кто я и зачем это пишу
Возможно, год назад вы видели мою первую, еще очень раннюю публикацию на эту тему vm5277, пример компиляции для AVR. За прошедшее время было написано огромное количество кода, исправлено не меньшее число багов и найдено множество не очевидных архитектурных решений. Изменился и инструмент: я успешно отложил в сторону плагин для NetBeans, который выпил из меня немало крови, и полностью переключился на IntelliJ IDEA.
Это первая статья из планируемого цикла. Чтобы у нас сразу сложилось правильное понимание, обозначу пару моментов:
Я не филолог и не академик по компиляторостроению. Я системотехник. Мой подход сугубо прагматичный: идти от конкретной инженерной задачи к её реализации. В моих статьях не будет заумной теории, только чистая практика.
Цель проекта — создать законченный тулкит, который кардинально снизит порог входа в embedded-разработку для маломощных МК и заметно сократит трудозатраты на написание и отладку кода. При этом — без потери производительности «на железе».
Текущий статус: Живая, но суровая Альфа
Проект находится в стадии активной альфы. Проделан колоссальный путь, но впереди работы еще больше. Для продакшена использовать тулкит, конечно, рано, но для ознакомления и экспериментов — самое оно.
Где посмотреть минусы: Список известных ограничений — по ссылке. Он будет обновляться по мере развития.
Где посмотреть код: В репозитории проекта на GitHub доступны десятки живых примеров — от банального мигания светодиодом до сложных драйверов периферии - ссылка.
Вот один из примеров ссылка
import boards.ArduinoUno; class Main { static public class Blink implements Timer { @Override public void run() { Gpio.invert(Gpio.PB5); } } public static void main() { Gpio.modeOut(ArduinoUno.LED_BUILTIN); Timer blink = new Blink(); while(true) { blink.start(100); Thread.sleep(3000); blink.start(30); Thread.sleep(3000); } } }
А здесь результат компиляции
; vm5277.avr:atmega328p, opt:size v0.5.0 .set OS_DRIVER_EXCEPTION_ID = 10 .set OS_BOUNDS_EXCEPTION_ID = 9 .set OS_NATIVE_EXCEPTION_ID = 8 .set OS_NULL_POINTER_EXCEPTION_ID = 11 .set OS_CALLBACK_IFACE_ID = 26 .equ CORE_FREQ = 16000 ;KHz .set STDIN_PORT_REGID = 11 .set STDIN_DDR_REGID = 10 .set STDIN_PIN_REGID = 9 .set STDIN_PORTNUM = 3 .set STDIN_PINNUM = 0 .set STDOUT_PORT_REGID = 11 .set STDOUT_DDR_REGID = 10 .set STDOUT_PIN_REGID = 9 .set STDOUT_PORTNUM = 3 .set STDOUT_PINNUM = 1 .set OS_FT_BLDR_API_REUSE = 1 .set OS_FT_DRAM = 1 .set OS_FT_MULTITHREADING = 1 .include "devices/atmega328p.def" .include "core/core.asm" .include "sys/mcu_halt.asm" .include "dmem/dram.asm" .include "j8b/class_refcount.asm" .include "core/dispatcher.asm" .include "j8b/mfin.asm" .include "j8b/new_thread.asm" .include "core/wait_ms.asm" Main: ldi r16,35 ldi r17,0x00 call j8bproc_new_thread std z+0x05,c0x02 movw pid_l,zl ldi r19,low(_OS_TASK_ENDPOINT) push r19 ldi r19,high(_OS_TASK_ENDPOINT) push r19 jmp j8b_CMainMmain _j8b_meta_518: .db 46,0 _j8b_meta_521: .db 47,1,33,1 .dw j8b_CMainCBlinkMrun_527 j8b_CMainCBlinkMrun_527: sbi pinb, 5 ret j8b_CMainCBlinkMBlink_532: ldi r16,low(35) ldi r17,high(35) call j8bproc_new_thread ldi r19,low(_j8b_meta_521*2) std z+0x03,r19 ldi r19,high(_j8b_meta_521*2) std z+0x04,r19 std z+0x05,c0x00 ldi r19,low(j8b_cmaincblinkmrun_527) std z+0x1d,r19 ldi r19,high(j8b_cmaincblinkmrun_527) std z+0x1e,r19 movw r16,r30 jmp j8bproc_mfin j8b_CMainMmain: sbi ddrb, 5 push r30 push r31 rcall j8b_CMainCBlinkMBlink_532 movw r20,r16 _j8b_loop_540: ldi r18,100 ldi r19,0 movw r16,r20 call os_timer_start_nr ldi r16,184 ldi r17,11 call os_wait_ms ldi r18,30 ldi r19,0 movw r16,r20 call os_timer_start_nr ldi r16,184 ldi r17,11 call os_wait_ms rjmp _j8b_loop_540
Вы можете лично запустить компилятор, оценить потребление ресурсов, скорость работы. А главное — посмотреть промежуточный ASM-код, чтобы оценить его оптимальность. Да, сейчас мой приоритет — расширение функционала, а не микрооптимизации. Но даже в текущем виде нативный выхлоп показывает более чем достойные результаты.
Поскольку готового комьюнити вокруг проекта пока нет, у вас есть возможность повлиять на вектор развития архитектуры и расширить инструмент, которого нам так не хватало.
За счет чего это работает? Архитектура экосистемы
Чтобы подружить строгий ООП-синтаксис с жесткими рамками 8-битного «железа», мне пришлось отказаться от идеи классических абстракций и построить экосистему на четырех базовых концептах:
Высокоуровневый J8B-язык: Для написания бизнес-логики используется язык с Java-подобным синтаксисом. Однако он имеет ряд критически важных отличий от стандартной Java, поскольку изначально спроектирован под МК с экстремально малым объемом ресурсов.
Платформонезависимый Runtime: Все базовые библиотеки и даже драйверы написаны на самом высокоуровневом языке. Они реализуют всю необходимую логику, но абстрагированы от конкретного железа. Написав один раз драйвер дисплея или протокола, вы без изменений переносите его между AVR, PIC, STM8 и т.д.
Системное ядро на чистом Ассемблере: Нижний уровень экосистемы пишется вручную под каждую архитектуру. Ассемблерное ядро берет на себя прямое взаимодействие с периферией, критичные к таймингам функции и предоставляет встроенную RTOS. Диспетчеризация потоков (как в кооперативном, так и в вытесняющем режимах) и управление динамической памятью здесь работают с минимально возможным оверхедом.
Собственный монолитный тулкит: Вместо зоопарка сторонних утилит я разрабатываю весь инструментарий с нуля на Java — компилятор, собственный ассемблер, прошивальщик, бутлоадер, LSP-сервер для подсветки синтаксиса и плагины для Maven, IDEA и NetBeans. За счет сквозной интеграции всех компонентов на этапе сборки удается проводить такие оптимизации кода, которые недоступны стандартным компиляторам, а сама сборка происходит существенно быстрее существующий решений.
Главные отличия vm5277 от C и Java: Разрушаем мифы
А теперь перейдем к деталям, из-за которых Си-разработчики обычно заявляют, что «это невозможно». Давайте наглядно сравним, как базовые задачи решаются в классическом Си, стандартной Java и в моей экосистеме.
Управление памятью: new без free и почему подсчет ссылок на 8-битном МК — это не больно
В мире Java разработчик не думает о памяти — там всем заправляет Garbage Collector (GC), который на микроконтроллере с 2 КБ ОЗУ устроил бы катастрофу из-за пауз и аппетита к ресурсам. В Си же мы пишем malloc / free вручную, регулярно ловя утечки памяти или fragmentation fault.
Как это решено в vm5277:
Вы используете привычный оператор new для создания объектов, но оператора free или delete в языке просто нет. Память освобождается автоматически прямо в момент, когда на объект больше никто не ссылается. Достигается это за счет подсчета ссылок (Reference Counting).
Но подсчет ссылок на AVR/PIC? Это же оверхед по памяти на хранение счетчиков и куча лишних тактов на каждый чих!
От части: Да, каждый объект имеет счётчик ссылок в HEAP. Но счётчик 8-битный, его обновление вынесено в отдельные ассемблерные процедуры и выполняется нечасто. Производительность получается сопоставимой с Си. Более того - этот язык спроектирован для 8 бит МК, а значит в основном в коде будут использоваться примитивы для которых счетчики не нужны.
Мы вынуждены платить за автоматическое освобождение памяти, я считаю данную реализацию вполне допустимой ценой.
В результате мы убираем целый класс ошибок, типичных для Си: утечки памяти, use-after-free, повторное освобождение. Мы получаем удобство разработки кода как в Java, где нам не нужно тратить большие усилия на управление памятью, при этом у нас нет Garbage Collector’а, что гарантирует детерминированное время выполнения.
Пример:
import boards.ArduinoUno; import drivers.sound.Buzzer; class Main { public static void main() { Buzzer buzzer = new Buzzer(ArduinoUno.PIN_D7, 150); buzzer.play(0b11101010100000000101); } }
j8b_CMainMmain: ;Формируем стек для вызова конструктора push r30 push r31 ldi r19,55 push r19 ldi r19,150 push r19 push c0x00 rcall j8b_CBuzzerMBuzzer_534 <-- Здесь создается объект Buzzer movw r20,r16 <-- Записываем ссылку в переменную (регистры r20,r21) ;Формируем стек для вызова метода play push r30 push r31 ldi r19,5 push r19 ldi r19,168 push r19 ldi r19,14 push r19 push c0x00 movw r30,r20 rcall j8b_CBuzzerMplay_571 <-- Вызов метода play ;Жизненный цикл переменной завершен, выполняем декремент счетчика movw r16,r20 call j8bproc_class_refcount_dec_nr jmp mcu_halt
Процедура RTOS с освобождением памяти
;----------------------------------------------------------- J8BPROC_CLASS_REFCOUNT_DEC_NR: ;----------------------------------------------------------- ;Декремент счетчика ссылок объекта ;IN: ACCUM_L/H-адрес HEAP ;MOD: ACCUM_L/H/EL/EH ;----------------------------------------------------------- CP ACCUM_L,C0x00 CPC ACCUM_H,C0x00 BREQ J8BPROC_CLASS_REFCOUNT_DEC__END MCALL OS_DISPATCHER_LOCK PUSH_Z MOVW ZL,ACCUM_L LDD ACCUM_EH,Z+0x02 CPI ACCUM_EH,0x00 BREQ J8BPROC_CLASS_REFCOUNT_DEC__SKIP2 DEC ACCUM_EH BRNE J8BPROC_CLASS_REFCOUNT_DEC__SKIP1 LDD ACCUM_L,Z+0x00 LDD ACCUM_H,Z+0x01 MCALL OS_DRAM_FREE J8BPROC_CLASS_REFCOUNT_DEC__SKIP1: STD Z+0x02,ACCUM_EH J8BPROC_CLASS_REFCOUNT_DEC__SKIP2: POP_Z MCALL OS_DISPATCHER_UNLOCK J8BPROC_CLASS_REFCOUNT_DEC__END: RET
ООП без оверхеда на ОЗУ: почему в языке нет наследования классов, а полиморфизм интерфейсов спрятан во Flash
Любой эмбеддер знает: тащить классическое наследование классов C++ на 8-битный микроконтроллер — это боль. Виртуальные функции требуют таблиц виртуальных методов (VMT) в ОЗУ, а приведение типов раздувает структуры.
Как это решено в vm5277: В нашем Java-подобном языке наследование классов запрещено на уровне архитектуры. Но полиморфизм нам необходим, поэтому язык поддерживает наследование интерфейсов (как в Go или Rust) и поощряет композицию.
Что это дает «в железе»?
Сам объект в ОЗУ остается плоской структурой, содержащей только его реальные переменные. Но как тогда работает полиморфизм без VMT в оперативке?
Компилятор берет всю тяжелую работу на себя и генерирует минимально необходимый RTTI (информация о типах во время выполнения), полностью упаковывая его в компактные метаданные во Flash-памяти. В ОЗУ под это не тратится ни одного байта.
Вот как выглядят реальные сгенерированные метаданные во Flash для точки входа Main и классов, реализующих интерфейсы дисплея HD44780 и расширителя портов PCF8574:
; Метаданные для драйвера HD44780 _j8b_meta_525: .db 45,3,10,0,37,1,38,2 ; Компактные байты идентификаторов (RTTI) .dw j8b_CHD44780Minit_656, j8b_CHD44780MsetCursor_719, j8b_CHD44780MputChar_726 ; Прямые адреса функций во Flash ; Метаданные для слоя PCF8574 _j8b_meta_529: .db 47,1,46,3 .dw j8b_CPcf8574LayerMsetNibble_554, j8b_CPcf8574LayerMpulse_566, j8b_CPcf8574LayerMsetRS_578
В чем профит:
Вы получаете привычную по Java гибкость интерфейсов, возможность легко подменять реализацию железа (например, переключить дисплей с параллельного интерфейса на I2C через PCF8574), но платите за это немного Flash-памятью. Оперативная память микроконтроллера не задействована.
Безопасность рантайма без оверхеда: как проверить деление на ноль и переполнение, не раздувая Flash
Первая реакция любого Си-разработчика: «Проверки переполнения и индексов в рантайме? Да это же дико замедлит процессор и сожрет копеечную Flash-память на кучу условных переходов!»
Написание прошивок на Си для 8-битных МК — это ходьба по минному полю. Ошиблись в индексе массива? Переполнился счетчик? Программа молча затрет соседние переменные в ОЗУ, и вы проведете пару ночей с осциллографом, пытаясь понять, почему железка зависает раз в сутки.
В Java безопасность абсолютная, но механизм исключений (Exception) весит слишком много для микроконтроллера.
Давайте снимем этот вопрос сразу. Никто не собирается бездумно пихать тяжелые проверки в каждый такт 8-битного процессора.
В vm5277 этот компромисс решен с помощью трех инженерных оптимизаций:
Аппаратные флаги вместо простыни кода: Для проверки арифметического переполнения на том же AVR зачастую достаточно всего одной копеечной инструкции — условного перехода по флагу C.
Вынос логики в RTOS: Более сложные проверки (например, выход за границы массива) не дублируются в коде каждый раз. Они вынесены в виде компактных системных функций в ассемблерное ядро RTOS. На месте проверки во Flash тратится лишь несколько байт на обычную инструкцию вызова подпрограммы (CALL).
**Проверки не безусловны (Главная фишка): ** Вы можете полностью отключить часть проверок на уровне компиляции для релизной прошивки, когда тестирование завершено.
Но самый хитрый фокус компилятора кроется в интеграции с легковесной системой try-catch. Компилятор не встраивает проверки переполнения во весь код подряд. Он делает это точечно — только там, где программист явно обернул блок в try-catch и ожидает потенциальную ошибку!
Код без try-catch: Компилятор работает в режиме «тихого Си» — переполнения игнорируются, оверхеда по скорости и Flash нет вообще.
Код внутри try-catch: Компилятор видит, что вы хотите обработать ошибку арифметики, и только в этот локальный участок внедряет быструю проверку флага.
В итоге вы сами решаете, где вам нужна абсолютная безопасность Java, а где — максимальная скорость и легкость чистого Си.
try { System.out("\nСложение с переполнением" + " в обработчике исключения" + "(runtime): " + (b2+0xffffffff)); } catch(MathOverflowException ex) { System.out("MathOverflowException"); }
ldi r16,255 ldi r17,255 ldi r18,255 ldi r19,255 add r16,r20 adc r17,c0x00 adc r18,c0x00 adc r19,c0x00 brcc _j8b_throwskip_10015 ldi r16,7 ldi r17,0x00 ldi r18,1 call j8bproc_etrace_addfirst rjmp _j8b_catch_554 _j8b_throwskip_10015:
Исключения (try-catch) на 8-битном МК: быстрая обработка ошибок и микростектрейс
Исключения в Java хороши тем, что при аварии мы получаем детальный стек-трейс: какая функция вызвала какую и где именно всё упало. В Си на МК при ошибке мы в лучшем случае получаем бесконечный цикл while(1), а в худшем — просто внезапный перезапуск по ватчдогу.
Как это решено в vm5277:
Под капотом в легковесной системе исключений нет никаких объектов в куче и тяжелой раскрутки стека. Вместо этого компилятор использует аппаратный статус-регистр процессора (флаг T в регистре SREG для архитектуры AVR).
Передача исключения наверх занимает минимум тактов на проверку инструкции brts. Но как понять, какой именно путь прошла ошибка до того, как упасть в catch или уйти в системную панику?
Для этого в ОЗУ выделяется крошечный кольцевой буфер. Когда метод возвращает взведенный флаг ошибки, компилятор подмешивает вызов j8bproc_etrace_add, который закидывает в буфер компактный ID точки прохода (всего 1-2 байта).
Когда система ловит критический сбой, ассемблерное ядро нашей RTOS раскручивает этот кольцевой буфер и выводит в цепочку вызовов.
Stacktrace for TestException, code:SECOND PROJECT/Main.j8b:120 TestException.throw(byte) PROJECT/Main.j8b:117 Main.method8() PROJECT/Main.j8b:114 Main.method7() PROJECT/Main.j8b:111 Main.method6() PROJECT/Main.j8b:108 Main.method5() ... PROJECT/Main.j8b:86 Main.method1() End stacktrace
В чем профит:
На выходе в терминале разработчик видит лаконичный лог ошибки в стиле 05.01>0A>02 (а прошивальщик в интерактивном режиме конвертирует его в человекочитаемый стектрейс с именами файлов и номерами строк), который мгновенно указывает на цепочку вызовов методов, приведших к сбою. При этом накладные расходы — это фиксированный кольцевой буфер на несколько байт в ОЗУ и запись ID и кода при возврате из методов. Полная безопасность и читаемость логов без ущерба для 8-битного «железа».
Процедура RTOS
.IFNDEF J8BPROC_ETRACE_ADD .include "j8b/etrace_clear.asm" ;----------------------------------------------------------- J8BPROC_ETRACE_ADDFIRST: ;----------------------------------------------------------- ;Устанавливаем в буфер ид типа исключения и код ;IN:ACCUM_L-ИД типа исключения, ACCUM_H-код, ACCUM_EL-ид ;строки в исходном коде (+ACCUM_EH в 15 бит режиме) ;----------------------------------------------------------- MCALL J8BPROC_ETRACE_CLEAR RCALL _J8BPROC_ETRACE_ADDFIRST__SET ;----------------------------------------------------------- J8BPROC_ETRACE_ADD: ;----------------------------------------------------------- ;Добавляем в буфер точку прохода(или переписываем последнюю) ;----------------------------------------------------------- PUSH_X PUSH TEMP_L LDI_X _OS_ETRACE_BUFFER+0x02 ;Перемещаемся на первую точку .IF OS_ETRACE_POINT_BITSIZE==0x07 PUSH ACCUM_L PUSH ACCUM_EL LDI TEMP_L,OS_ETRACE_BUFFER_SIZE-0x02 ;Количество итераций без последнего элемента _J8BPROC_ETRACE_ADD__LOOP: LD ACCUM_L,X+ ;Считываем первую точку CPI ACCUM_L,0x00 ;Проверяем, если свободна - переходим на запись BREQ PC+0x04 DEC TEMP_L ;Иначе продолжаем итерации BRNE _J8BPROC_ETRACE_ADD__LOOP ORI ACCUM_EL,0x80 ;Все элементы заполнены, пишем в последний включив признак переполнения SBIW XL,0x01 ST X,ACCUM_EL POP ACCUM_EL POP ACCUM_L .ELSE PUSH ACCUM_H PUSH ACCUM_L PUSH ACCUM_EH LDI TEMP_L,(OS_ETRACE_BUFFER_SIZE-0x02)/2 ;Аналогичная логика только для 15 битных элементов _J8BPROC_ETRACE_ADD__LOOP: LD ACCUM_H,X+ LD ACCUM_L,X+ CPI ACCUM_H,0x00 BRNE PC+0x03 CPI ACCUM_L,0x00 BREQ PC+0x05 DEC TEMP_L BRNE _J8BPROC_ETRACE_ADD__LOOP ORI ACCUM_EH,0x80 SBIW XL,0x02 ST X+,ACCUM_EH ST X,ACCUM_EL POP ACCUM_EH POP ACCUM_L POP ACCUM_H .ENDIF POP TEMP_L POP_X RET ;----------------------------------------------------------- _J8BPROC_ETRACE_ADDFIRST__SET: ;----------------------------------------------------------- ;Фиксируем Ид первого исключения и код ;----------------------------------------------------------- STS _OS_ETRACE_BUFFER+0x00,ACCUM_L ;В начале буфера записываем ид типа исключений STS _OS_ETRACE_BUFFER+0x01,ACCUM_H ;И код SET RET .ENDIF
Типы данных: почему все примитивы беззнаковые и зачем языку встроенный тип fixed (Q7.8)
В стандартной Java все числовые типы знаковые (signed), а результирующий тип данных в большинстве выражений будет как минимум int (4 байта), поскольку byte, short и char автоматически расширяются до int при участии в операциях. В эмбеддеде это порождает лишний расход памяти и проблемы: для работы с регистрами и битовыми масками нам критически необходимы беззнаковые типы (unsigned). Кроме того, работа с дробными числами через float на 8-битном МК без аппаратного математического сопроцессора — это гарантированный способ задушить производительность и раздуть Flash-память библиотеками программной эмуляции.
Как это решено в vm5277:
Я упростил систему типов Java, сделав её максимально близкой к аппаратуре микроконтроллера.
Во-первых, все базовые примитивные типы в языке являются беззнаковыми:
boolean (1 байт)
byte (1 байт) — аналог uint8_t в Си
char (1 байт) — экономный ASCII+KOI8-R-символ
short (2 байта) — аналог uint16_t в Си
int (4 байта) — аналог uint32_t в Си
Во-вторых, вместо тяжеловесного float я ввел уникальный для языков высокого уровня встроенный двухбайтовый знаковый примитив — fixed формата Q7.8 (1 байт на целую часть со знаком + 1 байт на дробную часть).
В-третьих, арифметические операции расширяют тип до минимально возможного, т.е. компилятор анализирует количество необходимых бит для результата и подбирает минимальный тип данных.
По умолчанию работают с тихим переполнением (как в C/C++) — для максимальной производительности
При оборачивании выражения в try-catch (ArithmeticOverflowException) выполняется проверка переполнения с генерацией исключения
Это дает разработчику выбор между скоростью и безопасностью в зависимости от задачи
Зачем нужен fixed?
Знак часто необходим для вычислений (например, показания температуры или вычисления дельты). Но знаковый float на 8 битах — это катастрофа по тактам. Тип fixed под капотом — это обычная 16-битная целочисленная математика. Для Q7.8 подходит тот же функционал арифметики, что и для 16-битной целочисленной, нужно только учитывать знак. В итоге арифметика fixed выполняется так же быстро, как обычные целочисленные операции — процессор щелкает эти вычисления мгновенно, а экономия тактов и Flash-памяти по сравнению с программным float получается многократной.
Тотальный Dead Code Elimination: в прошивку попадает только то, что реально вызвано
**На ПК мы привыкли не экономить:** если нам нужен один метод из библиотеки, мы подключаем её целиком, а неиспользуемые мегабайты просто лежат в памяти или на диске. На 8-битных МК такой подход упрется в потолок Flash-памяти в первые же дни разработки. Си-разработчики используют дефайны и флаги оптимизации линковщика, но они не всегда идеально справляются с ООП-абстракциями и внутренними зависимостями библиотек.
Как это решено в vm5277:
За счет того, что вся цепочка инструментов (компилятор → собственный ассемблер → ядро) написана с нуля как единый монолитный тулкит, я смог реализовать эффективный механизм оптимизации.
На уровне ООП-языка: Если вы создали огромный класс с десятками методов и драйверов, но вызвали только один метод — компилятор физически не будет генерировать код и метаданные для остальных методов. Аналогичная ситуация и с полями классов. Подключение «тяжелого» класса ради одной фичи стоит вам очень близко к размеру кода этой фичи.
На уровне ассемблерного ядра RTOS: Если ваша прошивка не использует, например, вытесняющую многозадачность, динамический диспетчер памяти или определенные системные функции ядра — эти куски ассемблерного кода просто не попадут в итоговый бинарник.
Мой тулкит собирает прошивку буквально по кирпичикам, отслеживая каждую живую связь от точки входа. Почти всё неиспользуемое безжалостно вырезается. Вы можете смело использовать богатые возможностями библиотеки, писать понятный и расширяемый ООП-код, зная, что итоговый нативный выхлоп во Flash будет чистым, компактным и избавленным от «мертвого» кода.
Двойная модель вызова и инлайнинг: как ООП-метод превращается в одну инструкцию ассемблера
В классической Java все аргументы при вызове методов гоняются через стек. На микроконтроллерах с архитектурой вроде AVR стек — ресурс дефицитный, а постоянное заталкивание (PUSH) и выталкивание (POP) регистров ради простого изменения состояния ножки процессора похоронило бы всю производительность. С другой стороны, писать всю прошивку на «голом» ассемблере — удовольствие сомнительное.
Как я решил эту проблему в vm5277:
Я заложил в компилятор двойную модель вызова методов (которая работает в том числе и через сгенерированный RTTI), разделив их по назначению:
Стековая модель: Используется для обычных высокоуровневых методов бизнес-логики. Она обеспечивает гибкость, понятную структуру программы и привычную ООП-разработку.
Регистровая модель: Применяется для вызова нативных методов ассемблерного ядра RTOS. Мой компилятор на этапе сборки точно знает спецификацию каждого нативного метода — какие аргументы он ждет и в каких конкретно регистрах процессора. Он не тратит такты на стек, а раскладывает значения напрямую по регистрам МК непосредственно перед вызовом. Таким образом оверхед на прыжок в нативный код становится меньше.
Но важный инструмент для эмбеддера — это инлайнинг кода. Компилятор умеет схлопывать высокоуровневые вызовы (там где это может дать профит). А также может заменить вызов нативного метода на небольшой блок инструкций если аргументы не используются либо выражены в виде констант.
Вот самый яркий пример. Я пишу красивый и понятный объектно-ориентированный код для управления периферией:
// Код на моем языке J8B Gpio.setHigh(Gpio.PD1);
Компилятор видит, что этот метод нативный и на входе константа, и вместо генерации вызова, передачи параметров и возврата из функции, он превращает эту строчку в одну единственную нативную инструкцию процессора:
; Сгенерированный компилятором нативный ASM для AVR sbi PORTD, 0x01 ; Выполняется за 1 такт, занимает 2 байта во Flash!
В чем профит:
Я получаю удобный синтаксический сахар Java, где работа с «железом» выглядит удобно и безопасно, но в итоговом бинарнике это работает с близкой к той же максимальной скорости и эффективности, как если бы я вручную писал этот кусок прошивки на чистом ассемблере.
Инфраструктура, экосистема и взгляд в будущее
Помимо ключевых низкоуровневых оптимизаций, в проекте реализовано множество архитектурных и сервисных фич. Они не требуют глубокого погружения в ассемблер, но именно они превращают компилятор в полноценную и удобную экосистему. Чтобы не раздувать первую статью, я объединю их в несколько наглядных блоков:
Синтаксис и сахар
Знакомая многопоточность: Поддержка таймеров и потоков, максимально близкая по своей логике и API к стандартной Java (Thread, Timer, TimerTask).
Бесплатные enum: Статическая реализация перечислений, которая вообще ничего не стоит для рантайма и итогового размера прошивки.
Удобные синтаксические фичи: Я добавил в язык конструкции, которых мне самому не хватало: цикл for с блоком else, операторы is (аналог instanceof) и as для безопасного приведения, а также switch с поддержкой диапазонов значений.
Аннотации-мосты: На аннотации возложена критическая задача — они связывают высокоуровневый код с ассемблерным ядром (это активно используется в драйверах, где часть логики написана на J8B, а тайминговые участки — на ассемблере).
Архитектура тулкита
Кроссплатформенная кодогенерация: Модель разделена на общий фронтенд-транслятор и сменные Java-библиотеки бэкенда, реализующие кодогенерацию под конкретные архитектуры МК.
Модульная изоляция: Экосистема жестко разделена на независимые модули. Это сделано специально, чтобы новые участники проекта могли комфортно подключиться к интересным им задачам — будь то развитие компилятора, написание ассемблерного ядра под новое семейство чипов или расширение высокоуровневого рантайма.
Встроенное логирование: Из коробки доступна продвинутая база для вывода логов, которую можно перенаправить на любой порт МК, поддерживающий обычный GPIO.
Инструментарий и Роадмап
Прошивальщик: Мой собственный прошивальщик с удобным интерактивным режимом для работы с кристаллами.
Плагин для IntelliJ IDEA: Написан богатый по функционалу плагин для комфортной разработки в привычной IDE (подсветка, автодополнение, интеграция со сборщиком), хотя плагин еще активно дорабатывается.
Отладка по 1–2 пинам (В планах): В будущем я планирую реализовать полноценную отладку высокоуровневого кода прямо на железе, задействуя всего 1 или 2 вывода микроконтроллера (это уже используется для прошивки).
Виртуальная машина (В планах): Имя проекта VM изначально закладывалось не просто так. Для мощного «железа» (32 и 64 бита) в будущем планируется разработка полноценной компактной виртуальной машины.
О недостатках честно: с чем придется смириться на этапе Альфы
Проект масштабный, и я не собираюсь скрывать его текущие ограничения. На Хабре ценят честность, поэтому давайте сразу обозначим болевые точки экосистемы в её нынешнем состоянии:
Это НЕ Java: Это самостоятельный язык с Java-подобным синтаксисом. Многие привычные фичи JDK здесь либо неактуальны, либо просто не поместятся в микроконтроллер. У меня нет (и не будет в привычном виде) тяжелых коллекций с объектами в качестве ключей, Generics, Stream API или автоматического приведения любых выражений к типу int. Синтаксис Java я выбрал исключительно ради высокой читаемости кода и низкого порога входа для прикладных разработчиков.
Проекту 1.5 года, и я пишу его в одиночку: Вокруг платформы пока нет комьюнити, полноценной хорошей документации и готовой базы ответов на StackOverflow. Всё создается с нуля.
Реализация пока только для платформы AVR: Архитектура заложена гибкая, но «в железе» компилятор пока умеет работать лишь с небольшим семейством чипов AVR (в основном тестирую на ATmega328).
Высокая цена переноса на новые архитектуры: Чтобы запустить экосистему на условном STM8, PIC или Z80, для каждой платформы нужно написать (а точнее портировать существующие) три вещи: поддержку её ассемблера в тулките, Java-библиотеку бэкенд-кодогенератора и системное ядро (RTOS) на нативном ассемблере этого чипа.
Это жесткая Альфа: Я развиваю проект прагматично, отталкиваясь от своих текущих задач. Это значит, что вы легко можете наткнуться на глупые баги, недоделанный функционал или методы, которые покрывают только базовые сценарии. Сейчас я мало сил уделяю микрооптимизациям, хотя сгенерированный нативный код уже получается вполне приличным, а главное — он легко читается человеком.
Все не так плохо, как кажется.
Да, объем работы для поддержки новых платформ выглядит пугающе, но под капотом всё устроено максимально дружелюбно к разработчику.
Во-первых, прикрутить бэкенд для условного STM8 не так сложно: я постарался вынести максимум общей логики в общий бэкенд. Библиотеку под новую платформу можно писать по аналогии, взяв за основу готовую библиотеку от AVR.
Во-вторых, ассемблерное ядро RTOS тоже не придется придумывать с нуля. Его логика, структура диспетчеризации и алгоритмы управления памятью будут максимально отлажены (когда я завершу платформу AVR). Задача портирования сводится к переносу этой логики на систему команд и регистры другого процессора (архитектурно те же STM8 или старые добрые Z80 во многом концептуально понятны).
Отсутствие комьюнити на этапе альфы — это нормально. Я надеюсь, что опытные инженеры оценят заложенные преимущества и архитектуру vm5277, увидят здесь перспективу и помогут проекту вырасти.
Анатомия тулкита: из чего состоит экосистема сборки
Чтобы получить максимальную интеграцию и независимость от стороннего софта, я написал всю цепочку утилит с нуля на Java. Архитектурно экосистема разделена на несколько ключевых компонентов, которые ведут проект от исходного кода до прошивки в чипе:
1. Компилятор и Ассемблер
j8bc (компилятор): Берет ваш исходный код, автоматически подключает к нему рантайм (и внешние библиотеки, если они используются) и транслирует всё это в читаемый ассемблерный файл. Этот файл генерируется под конкретный чип с его специфичными характеристиками. При желании вы можете открыть его в IDE или текстовом редакторе, изучить каждую строчку или вручную что-то подправить.
avrasm (ассемблер для AVR): Принимает сгенерированный ассемблерный файл и собирает его в финальный бинарник прошивки для выбранного кристалла. Именно на этом этапе подгружаются необходимые низкоуровневые библиотеки ассемблерного ядра RTOS.
2. Флешер и Бутлоадер
j8bf (флешер): Работает в паре с моим бутлоадером на чипе. Флешер в полностью автоматическом режиме определяет метод подключения, считывает характеристики и идентификаторы МК, после чего выполняет быструю заливку прошивки.
Интерактивный режим флешера (Фишка): После прошивки утилита не закрывается, а может перейти в интерактивную консоль. Она перенаправляет ввод-вывод программного UART микроконтроллера в консоль ПК. Более того, флешер на лету подхватывает те самые служебные данные исключений от МК (о которых я писал в блоке про try-catch) и преобразует их в человекочитаемый стек-трейс прямо в консоли.
Bootloader: Прошивается в МК один раз. Помимо связи с флешером, в будущем он будет активно задействован для реализации сквозной отладки высокоуровневого кода.
3. Интеграция и IDE
Maven-плагин: Интегрирует компилятор в стандартный жизненный цикл сборки. Разработка и компиляция проекта для микроконтроллера выглядит и управляется точно так же, как сборка классического Java-приложения на ПК.
LSP (Language Server Protocol): Сердце языковой поддержки (еще не все реализовано). Этот сервер интегрирует лексер, AST-парсер и семантический анализатор моего компилятора с редактором кода. Сейчас он используется в плагине для IDEA, но архитектура позволяет в будущем легко перенести поддержку языка в сторонние редакторы (VS Code, Kate, Neovim и т.д.).
Плагин для IntelliJ IDEA: Универсальное рабочее пространство, которое я сейчас активно развиваю. Он обеспечивает полноценную и удобную работу в одной IDE как с высокоуровневым языком J8B (подсветка, автодополнение благодаря LSP), так и с нативным ассемблером (в будущем будут дополнительные возможности, например навигация).
Заключение: мне нужны евангелисты
Давайте подведем итог. Напомню то, с чего я начал этот разговор: я не профессиональный писатель, не компиляторщик с академическим бэкграундом и не филолог. Я инженер-системотехник, который привык идти от конкретной боли к её осязаемому решению. Мне не хватало удобного инструментария для 8-битных микроконтроллеров — и я создаю его сам, таким, каким я его вижу.
Сейчас проект vm5277 подошел к черте, когда двигаться в одиночку дальше не имеет особого смысла. Я не верю в сухой маркетинг — считаю его вторичным.
Мне нужны живые люди, которые увидят в этом проекте смысл и ценность. Люди, которым будет близка сама идея удобного, безопасного ООП-подхода для скромного, но надежного и широко распространенного 8-битного «железа». Проекту нужны технари-экстраверты — настоящие евангелисты платформы, которые:
Захотят крутить этот тулкит, тестировать его и ломать в хвост и в гриву.
Будут искать, где применить этот стек в реальных задачах.
Помогут мне выстраивать приоритеты развития архитектуры и проекта в целом.
Станут рассказывать о проекте другим, привлекая новых участников.
При этом я бы хотел остаться на текущем месте - т.е. продолжать дорабатывать проект надеясь, что он станет полезен обществу.
Экосистема изначально спроектирована модульно. Если вы хотите приложить руку к коду — для вас открыты любые направления: от оптимизации компилятора до портирования ассемблерного ядра RTOS под новые платформы (например, под те же STM8 или PIC), расширения рантайм-библиотек или шлифовки плагина для IDEA.
Их пока нет — тех людей, которые вдохнут в этот проект полноценную жизнь.
И я не умею выстраивать сообщество. Я одиночка, который последние полтора года писал код. Комьюнити-менеджмент, организация вклада, модерация обсуждений — это отдельная работа, на которую у меня нет ни времени, ни компетенции.
Поэтому мне нужен не просто пользователь, а человек, который захочет стать связующим звеном между проектом и людьми. Технарь, который понимает ценность идеи, но при этом умеет общаться, объяснять, вовлекать и выстраивать процессы. Я пишу код, он — сообщество.
Забегая вперед. Я понимаю, что после этой статьи возникнет много вопросов — справедливой критики и не очень. Требования показать бенчмарки, разобрать конкретные сценарии, сравнить с Си — всё это закономерно.
Это не последняя статья. Я постараюсь ответить на ваши вопросы в следующих материалах.
Ваши комментарии под этим постом — они помогут определить, о чём писать дальше.
Полезные ссылки:
Точка входа — предлагаю начать с этой инструкции
vm5277.ru — основной сайт
github.com/w5277c/vm5277 — весь проект, включая компилятор, ассемблер, ядро RTOS и тулкит.
github.com/w5277c/vm5277/tree/main/examples/j8b — примеры программ на J8B.
github.com/w5277c/vm5277/tree/main/docs — документация (пока сырая и местами не актуальная)
Telegram группа - группа в телеграмме
P.S. Я написал этот текст с помощью нейросети.
Потому что это логичный инструмент, позволяющий экономить время и выдавать структурированный материал. Не вижу смысла притворяться писателем.
Если у проекта появится сообщество, я готов делегировать академическую часть и документацию тем, кому это интересно.
Комментарии (4)

Jijiki
31.08.2026 11:11ну тоесть получается на десктопе вся та портянка зависимости получается ляжет в память по счетчику ссылок или только родители/дети, я не знаю как в ембединге вежет себя ява и не до конца понимаю внутрянку языка, но там помойму внутрянка решающая, тоесть это возможно придётся переписать весь язык, ведь нью может запускать каскад нью под капотных типо нетривиальных, они тоже подцепляются на счетчики интересно, если так, то на интрузивном счетчике ссылок на С++ можно целый аналог явы чтоли сварганить ?) мне кажется это было бы проще, хотя то как ява оптимизирована в плане запуска байт-кодов восхищает конеш........ интересно очень, но ничо не понятно )))

lgorSL
31.08.2026 11:11Вопрос про подсчёт ссылок: кольцо из ссылающихся объектов приведёт к утечке? Сборщик мусора двигает объекты в памяти при сборке или нет? Если нет - как решается проблема фрагментации? Есть ли escape analysis, когда объект создаётся внутри функции, гарантированно не утекает и тут же выкидывается? (Тогда можно вообще счётчик ссылок не заводить)
И ещё одно замечание про оверхед со ссылками и прочее - в С# в системе типов изначально были ещё и структуры (а в java их всё никак не приделают), и кажется что для МК они нужны. Идея структуры в том, что она передаётся по значению (прям как си), и позволяет создавать меньше объектов и плотнее хранить данные в памяти.
P.s. я сам пробовал задизайнить язык, но я правда пришёл к ещё более радикальной идее - у меня один и тот же класс может передаваться и по ссылке и по значению. У меня у ссылок на объект в куче тип Gc[Smth], а у объекта по-значению - Smth, и можно один и тот же класс точечно и на куче создавать и на стеке (например, если я как программист знаю, что он временный).
P.p.s. какие-то мысли про язык и процесс разработки (сам язык сильно отличается, но надеюсь какие-то вещи могут пригодиться) : https://kright.me/2026/07/06/delaiu-svoi-iazyk-programmirovaniia/

mozg37
31.08.2026 11:11Вот сугубо практическая задача. dma принимает с внешнего источника пакеты - кольцевой буфер, прерывания на середине и конце буфера - ну как обычно. Как это на этой недожаве реализовывать?
AnthonyDS
Классно, нужно будет попробовать)