QEMU — это не просто виртуализация с KVM. Его главная суперсила — способность запускать скомпилированные для одной архитектуры программы на совершенно другом процессоре. Безо всякой поддержки со стороны хоста. Например, можно запустить прошивку для ARM-микроконтроллера на своем x86-ноутбуке.
Как это работает? Под капотом QEMU скрывается JIT-компилятор под названием TCG (Tiny Code Generator). В этой статье мы разберем его устройство на практических примерах для RISC-V и посмотрим, как инструкции превращаются из одного машинного кода в другой, как формируются блоки трансляции и зачем там нужны longjmp и «цепочки» блоков.
Введение
QEMU — удивительный инструмент. Для большинства пользователей он ассоциируется с быстрой виртуализацией (KVM), позволяющей запускать Linux под Linux с почти нативной производительностью. Но есть у QEMU и другая, не менее важная ипостась — эмуляция. Именно она позволяет запускать операционную систему для ARM на вашем x86-ноутбуке или тестировать прошивку для RISC-V-микроконтроллера.
Сердцем этой эмуляции является Tiny Code Generator (TCG) — механизм динамической трансляции (JIT), который превращает инструкции одной архитектуры в инструкции другой. В этой статье мы подробно разберем, как устроен TCG, как он работает и как исследовать его внутренности.
TCG, KVM и режимы эмуляции
QEMU может работать в трёх основных режимах:
User TCG (user-mode) — эмуляция отдельного Linux-приложения. Системные вызовы транслируются в вызовы ядра хоста. Быстро, но работает только под Linux/BSD.
System TCG (softmmu) — эмуляция целой системы: процессора, памяти, периферии (UART, GPIO, АЦП и т. д.). Именно этот режим позволяет «крутить» операционную систему для другой архитектуры.
Гипервизор — аппаратная виртуализация. QEMU здесь выступает лишь как настройщик и обработчик исключений, например,
KVM_EXITпри обращении к MMIO. Процессор не эмулируется, а исполняется нативно.
В режиме TCG (и user, и system) QEMU — это обычная пользовательская программа, не требующая прав root, гипервизор второго типа. KVM же — гипервизор первого типа, работает только на Linux.
Параметр |
System TCG |
KVM |
Тип гипервизора |
Тип 2 |
Тип 1 |
Хост-ОС |
Windows, Linux, BSD, macOS |
Linux |
Доступ |
обычный пользователь |
|
CPU |
эмулируется |
такой же, как в системе |
MMIO |
да |
да |
VirtIO |
да |
да |
Системный режим в терминах QEMU называется softmmu, это не связано с наличием MMU у гостя. Тем более что наличие не подразумевает обязательного использования.
На самом деле список гипервизоров, или, в терминах QEMU, акселераторов достаточно обширен, но я не сталкивался и не интересовался чем-то за пределами Linux.
Что представляет собой TCG — Tiny Code Generator
Изначально TCG появился как часть маленького компилятора C (TinyCC), затем был адаптирован для QEMU. Это JIT-компилятор (just-in-time), но в отличие от классических JIT (например, в JVM), он транслирует не байт-код, а машинные инструкции одной архитектуры в машинные инструкции другой. Никакого интерпретатора — только трансляция «код → код».
TCG условно делится на три части:
Декодер — превращает машинный код целевой архитектуры в промежуточное представление (IR), состоящее из TCG Ops.
Промежуточное представление — набор операндов TCG Ops.
Генератор — транслирует TCG Ops в нативный машинный код хоста.
Если собрать QEMU с ключом --enable-tcg-interpreter, то можно получить режим интерпретатора TCI, где TCG Ops выполняются напрямую, без трансляции в нативный код. Это очень медленно, но полезно для отладки или для архитектур без поддержки TCG.

Qemu/target — это что мы эмулируем, а qemu/tcg — на чем эмулируем. В принципе, очевидно, что эмулировать мы можем любую архитектуру, а для поддержки эмуляции нам нужна полноценная ОС.
User TCG vs System TCG
Оба используют один и тот же механизм TCG. Однако user-mode не эмулирует периферию и не создает виртуальную машину — он транслирует системные вызовы гостевой программы в системные вызовы хоста. Поэтому user-mode работает только под Linux/BSD. System TCG эмулирует всё: память, прерывания, MMIO, таймеры и т. д. Соответственно, он доступен на более широком спектре OS — например, для Windows и MacOS.
Рассмотрим программу, работающую в пользовательском пространстве Linux. В режиме user-mode QEMU эта программа выполняется внутри специальной обертки: все системные вызовы транслируются напрямую ядру хоста. По сути, она взаимодействует с ядром так же, как и обычная нативная программа для данной архитектуры.
В режиме system-mode ситуация иная. Здесь та же самая программа (гостевая) общается уже с гостевым ядром, а не с хостовым. Вся коммуникация с внешним миром (дисплей, сеть, диски, последовательные порты) осуществляется через MMIO-обращения и эмуляцию периферии, которую полностью берет на себя QEMU.

На диаграмме широкими белыми стрелками обозначено прямое взаимодействие, а красными — косвенное, которое QEMU осуществляет как пользовательская программа, например, работу с сетью или файловой подсистемой.
В этой статье мы сконцентрируемся именно на System TCG.
Сборка QEMU из исходников, для экспериментов
Для изучения TCG понадобится отладочная сборка. Рекомендуется использовать специальный репозиторий с патчами TCG code quality tracking для:
$ git clone https://gitflic.ru/project/maquefel/qemu-jit-playground
Если собирать самостоятельно:
$ sudo apt install libcapstone-dev capstone-tool $ git clone -b nshubin/tcg-code-quality https://gitflic.ru/project/yadro-k/qemu qemu $ mkdir build-qemu $ cd build-qemu $ ../qemu/configure --target-list="riscv32-softmmu,riscv64-softmmu" \ --enable-debug --enable-debug-tcg --extra-cflags="-Wno-unused-function" $ make -j$(nproc) -s
Параметр --enable-debug-tcg включает дополнительные проверки и отладочные возможности, в том числе поддержку -d out_asm,in_asm,op,op_opt.
Riscv-tests: набор для верификации RISC-V
Для тестирования и демонстрации работы TCG мы будем использовать официальный набор тестов riscv-tests, который был немного дополнен. Он предназначен для проверки новых имплементаций RISC-V‑ядер на соответствие спецификации The RISC-V Instruction Set Manual. Каждый тест представляет собой небольшую программу на ассемблере или C, которая выполняет определенную инструкцию или группу инструкций и проверяет результат. В случае ошибки тест переходит в fail‑секцию, а при успехе — в pass.
Например, в isa/rv64ui/add.S проверяется работа инструкции add на нулевых операндах:
TEST_RR_OP(2, add, 0x00000000, 0x00000000, 0x00000000);
После препроцессинга и компиляции получается готовый ELF‑файл, который можно загрузить в QEMU.
Установка инструментов и сборка
$ sudo apt install gcc-riscv64-unknown-elf picolibc-riscv64-unknown-elf $ git clone https://gitflic.ru/project/maquefel/riscv-tests $ cd riscv-tests $ ./configure $ make -j$(nproc)
Собранные тесты — например, isa/rv32ui-p-add — можно запускать в QEMU в режиме system TCG через ключ -bios, так как они ожидают загрузку по адресу 0x80000000, который в машине virt является началом RAM). Эти тесты удобны для изучения TCG, потому что они короткие, не требуют операционной системы и дают предсказуемое поведение: циклы, ветвления, обращение к памяти. Другие преимущества для экспериментов с TCG:
Небольшой размер TranslationBlock (легко анализировать
in_asm,op,out_asm).Разные типы инструкций: арифметические, ветвления, переходы, а иногда и обращения к MMIO (UART).
Возможность строить графы переходов блоков и смотреть, как QEMU оптимизирует константные выражения.
Более сложные примеры — например, virt32-pt-loop — добавляют бесконечные циклы и условные переходы, на которых хорошо видна работа goto_tb и цепочек блоков.
Запуск QEMU машины
Для экспериментов с TCG будем использовать встроенную машину virt. Она доступна для всех целевых архитектур и не привязана к реальному железу. В случае RISC-V эта машина обладает следующими характеристиками:
RAM начинается с адреса
0x80000000.Есть UART, таймер, PLIC (контроллер прерываний), virtio-устройства.
Загрузка возможна через
-bios(ELF или бинарник) или через загрузчик-device loader.
Базовая команда для запуска 32‑битного RISC‑V-теста:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/isa/rv32ui-p-add -nographic -serial mon:stdio
Разберем ключи:
-M virt— выбрать машинуvirt.-bios <файл>— загрузить ELF или бинарный образ по адресу, который задан в его заголовке. Тестыriscv-testsожидают стартовый адрес0x80000000, и он совпадает с началом RAM уvirt, поэтому ключ работает без дополнительных настроек.-nographic— отключить графическое окно, направить ввод‑вывод в консоль.-serial mon:stdio— связать последовательный порт (UART) со стандартным потоком ввода‑вывода. Это позволяет выводить символы из гостевой программы прямо в терминал. Одновременно в ту же консоль попадает монитор QEMU (по нажатию Ctrl+A, затем C).
Альтернативный способ загрузки, когда у вас есть только сырой бинарник (flat binary) или нужно указать произвольный адрес:
$ build-qemu/qemu-system-riscv32 -M virt -nographic -serial mon:stdio -device loader,file=firmware.bin,addr=0x80000000
Или же загрузка ELF-файла, так как QEMU умеет обрабатывать формат ELF:
$ build-qemu/qemu-system-riscv32 -M virt -nographic -serial mon:stdio -device loader,file=riscv-tests/isa/rv32ui-p-add
После запуска можно управлять эмуляцией через монитор: остановить, посмотреть состояние памяти, регистров, а также вывести список текущих TranslationBlock (info tb-list), если QEMU собран с патчами для трекинга качества кода.
Для отладки трансляции (просмотр in_asm, op, op_opt, out_asm) в команду добавляют ключ -d с нужными флагами и ограничивают вывод фильтром -dfilter.
Простой пример: сложение двух нулей (RISC-V)
Для демонстрации возьмём тест из riscv-tests, который проверяет инструкцию add. Исходный макрос:
TEST_RR_OP( 2, add, 0x00000000, 0x00000000, 0x00000000 );
После препроцессинга превращается в:
test_2: li gp, 2 li a1, 0 li a2, 0 add a4, a1, a2 li t2, 0 bne a4, t2, fail
Скомпилируем и посмотрим дамп (objdump):
8000017c <test_2>: 8000017c: 00200193 li gp,2 80000180: 00000593 li a1,0 80000184: 00000613 li a2,0 80000188: 00c58733 add a4,a1,a2 8000018c: 00000393 li t2,0 80000190: 4c771663 bne a4,t2,8000065c <fail>
Отладочные ключи: как заглянуть внутрь трансляции
QEMU умеет показывать каждый этап трансляции. Основные ключи:
-d in_asm— входной ассемблер целевой архитектуры (то, что поступает на вход TCG);-d op— промежуточное представление (TCG Ops) без оптимизаций;-d op_opt— после оптимизаций;-d out_asm— итоговый нативный код хоста (например, x86_64);-dfilter 0x8000017c+0x14— фильтровать вывод только для диапазона адресов.
Запускаем с фильтром, чтобы ограничить вывод интересующим нас участком:
$ qemu-system-riscv32 -dfilter 0x8000017c+0x14 -d in_asm
in_asm - что видит TCG на входе
IN: 0x8000017c: 00200193 addi gp,zero,2 0x80000180: 00000593 mv a1,zero 0x80000184: 00000613 mv a2,zero 0x80000188: 00c58733 add a4,a1,a2 0x8000018c: 00000393 mv t2,zero 0x80000190: 4c771663 bne a4,t2,1228
Полностью совпадает с objdump — это и есть исходный код гостя.
op - промежуточное представление (без оптимизаций)
$ qemu-system-riscv32 -dfilter 0x8000017c+0x14 -d op
Вывод сокращенно, без пролога и эпилога:
---- 000000000000017c add_i32 x3/gp,$0x0,$0x2 ---- 0000000000000180 mov_i32 x11/a1,$0x0 ---- 0000000000000184 mov_i32 x12/a2,$0x0 ---- 0000000000000188 add_i32 x14/a4,x11/a1,x12/a2 ---- 000000000000018c mov_i32 x7/t2,$0x0 st8_i32 $0x1,env,$0xfffffffffffffffc
Здесь add_i32, mov_i32 — это внутренние TCG-операции. Обратите внимание, что add RISC-V превратился в add_i32. Виртуальные регистры (x3/gp, x11/a1) — это абстракции, которые позже будут отображены на реальные регистры хоста или на стек. Это зависит от количества регистров хостовой машины — например, для i386 регистров очень мало и абстракции будут на стеке.
Оптимизации QEMU
Между стадиями op и op_opt происходит несколько проходов:
tcg_optimize()— упрощение констант, свертка выражений.reachable_code_pass()— удаление недостижимого кода.liveness_pass_0()иliveness_pass_1()— анализ живых переменных; если результат операции нигде не используется, операция удаляется.
В нашем примере add_i32 x3/gp,$0x0,$0x2 заменяется на mov_i32 x3/gp,$0x2, потому что прибавление к нулю — это просто присваивание. Вот как выглядит op_opt:
---- 000000000000017c mov_i32 x3/gp,$0x2 sync:0 dead:0 1 pref=0xffff ---- 0000000000000180 mov_i32 x11/a1,$0x0 sync:0 dead:0 pref=0xffff ---- 0000000000000184 mov_i32 x12/a2,$0x0 sync:0 dead:0 pref=0xffff ---- 0000000000000188 mov_i32 x14/a4,$0x0 sync:0 dead:0 pref=0xffff ---- 000000000000018c mov_i32 x7/t2,$0x0 sync:0 dead:0 1 pref=0xffff st8_i32 $0x1,env,$0xfffffffffffffffc dead:0 1
Дополнительные поля:
sync— требуется синхронизация с глобальным состоянием (многопоточность).dead— количество операндов, которые не нужны.pref— маска предпочтительных регистров для хоста (например, на x86_64 регистров мало, и транслятор может подсказывать, какие из них желательно использовать).
out_asm — итоговый нативный код (x86_64)
Финальная стадия — генерация машинного кода хоста:
$ qemu-system-riscv64 -dfilter 0x8000018c+0x14 -d out_asm
Пример фрагмента (i386):
-- guest addr 0x0000000000000190 0x7a872d439f56: c7 45 2c 00 00 00 00 movl $0, 0x2c(%rbp) -- guest addr 0x0000000000000194 0x7a872d439f5d: c7 45 30 00 00 00 00 movl $0, 0x30(%rbp) -- guest addr 0x000000000000019c 0x7a872d439f6b: c7 45 1c 00 00 00 00 movl $0, 0x1c(%rbp) 0x7a872d439f72: c6 45 fc 01 movb $1, -4(%rbp)
Это уже настоящий x86-код, который будет исполнен процессором. Обратите внимание: он оперирует со смещениями относительно %rbp — так эмулируются регистры гостя. У архитектуры RISC-V регистров намного больше, чем у i386, что сказывается на сложности эмуляции не в лучшую сторону.
TranslationBlock — единица трансляции
QEMU не транслирует всю программу целиком, а разбивает ее на трансляционные блоки (Translation Block, TB). Каждый TB — это непрерывная последовательность гостевых инструкций, которая всегда заканчивается:
инструкцией ветвления или перехода (
beq,jalrи т.д.);выходом за пределы текущей страницы памяти (из-за возможного изменения прав доступа);
специальной инструкцией (например,
wfi— wait for interrupt, работа с CSR);достижением лимита инструкций в блоке (по умолчанию около 64).
Следует отметить, что с точки зрения QEMU инструкции семейства lw/sw (загрузить из адресного пространства / сохранить в адресное пространство) могут относиться к двум разным типам: «обычная» память (RAM, SRAM, ROM и т. д.) и MMIO (устройства ввода-вывода). Поскольку QEMU заранее не знает, что именно расположено по целевому адресу, он не завершает формирование TranslationBlock на таких инструкциях — они могут быть включены внутрь блока. Однако при исполнении, если выясняется, что адрес относится к MMIO, QEMU прерывает исполнение текущего блока и обрабатывает обращение к устройству отдельно.
Почему исполнение блока прерывается на MMIO операциях? Потому что запись/чтение MMIO может изменить состояние системы (вызвать или сбросить прерывание, изменить карту памяти и пр.), и QEMU должен вернуться в основной цикл, чтобы корректно обработать это событие. Подробнее разберем позже на примере вывода в UART.
Структура TranslationBlock
В исходниках QEMU TB определен как структура, содержащая, среди прочего:
pc— виртуальный адрес начала блока (с точки зрения гостя);size— размер в байтах;icount— количество инструкций гостя в блоке;tc.ptr— указатель на сгенерированный нативный код;jmp_list_head,jmp_list_next[2],jmp_dest[2]— для организации связей между блоками (цепочки переходов).
// include/exec/translation-block.h struct TranslationBlock { vaddr pc; uint16_t size; uint16_t icount; struct tb_tc tc; uintptr_t jmp_list_head; uintptr_t jmp_list_next[2]; uintptr_t jmp_dest[2]; };
Этим содержание не ограничивается, но для усвоения текущего материала нам хватит и перечисленного.
Жизненный цикл TB
Основной цикл исполнения QEMU упрощенно:
Cpu_exec_loop() — главный цикл.
Проверка прерываний/исключений.
Поиск TB по текущему
pcв кэше (tb_lookup).-
Если TB не найден — вызов
tb_gen_code(), который:декодирует инструкции гостя, начиная с
pc, пока не встретит условие завершения блока;генерирует TCG Ops;
оптимизирует;
генерирует нативный код (размещает его в буфере кода);
сохраняет TB в кеш.
Исполнение TB через вызов указателя на
tc.ptr.После завершения TB управление возвращается в
cpu_exec_loopи цикл повторяется.

Ключом поиска и сохранения блока в кеше служит значение pc.
Кеш блоков
TranslationBlock (TB) живут в общем буфере кода (code generation buffer), размер которого задается при старте QEMU и определяет, сколько сгенерированного кода может быть сохранено до принудительной очистки.
По умолчанию для системной эмуляции выделяется 1 ГБ (128 МБ в user-mode), что рассчитано для баланса между производительностью и нагрузкой на хост, а минимальный размер составляет 1 МБ. Максимальный же размер буфера жестко ограничен архитектурой хоста: для x86_64 и SPARC64 это 2 ГБ, для s390x — 3 ГБ, что связано с диапазоном прямых переходов в реализации goto_tb. Когда буфер переполняется, все TB сбрасываются целиком и цикл генерации начинается заново.
Значения по умолчанию определены в tcg/region.c:
/* * Minimum size of the code gen buffer. This number is randomly chosen, * but not so small that we can't have a fair number of TB's live. * * Maximum size, MAX_CODE_GEN_BUFFER_SIZE, is defined in tcg-target.h. * Unless otherwise indicated, this is constrained by the range of * direct branches on the host cpu, as used by the TCG implementation * of goto_tb. */ #define MIN_CODE_GEN_BUFFER_SIZE (1 * MiB) #ifdef CONFIG_USER_ONLY /* * As user-mode emulation typically means running multiple instances * of the translator don't go too nuts with our default code gen * buffer lest we make things too hard for the OS. */ #define DEFAULT_CODE_GEN_BUFFER_SIZE_1 (128 * MiB) #else /* * We expect most system emulation to run one or two guests per host. * Users running large scale system emulation may want to tweak their * runtime setup via the tb-size control on the command line. */ #define DEFAULT_CODE_GEN_BUFFER_SIZE_1 (1 * GiB) #endif
Помимо значений по умолчанию, существуют также жесткие лимиты для некоторых архитектур:
./tcg/x86_64/tcg-target.h:31:#define MAX_CODE_GEN_BUFFER_SIZE (2 * GiB) ./tcg/sparc64/tcg-target.h:30:#define MAX_CODE_GEN_BUFFER_SIZE (2 * GiB) ./tcg/s390x/tcg-target.h:31:#define MAX_CODE_GEN_BUFFER_SIZE (3 * GiB)
Время жизни Translation Blocks (TB)
Полная очистка кеша
Этот механизм сбрасывает все существующие TB. Он вызывается в следующих случаях:
Достигнут лимит кеша. В ядре TCG нет реализации политик вытеснения, например, LRU (Least Recently Used).
Изменение глобальных настроек эмуляции. Например, при изменении флагов отладки (
-d) или при переключении режима, который влияет на генерацию кода для всех TB.Сброс (Reset) CPU. При сбросе виртуального процессора состояние системы кардинально меняется, поэтому весь ранее сгенерированный код становится невалидным и требует очистки.
Загрузка состояния (VMState). При загрузке снапшота или миграции состояния виртуальной машины ключи (адреса), по которым TB ищутся в кеше, могут соответствовать уже другому коду. Поэтому кеш необходимо полностью сбросить.
Запрос от плагинов. Плагины QEMU могут программно инициировать очистку кеша через вызов
qemu_plugin_flush_tb_cache(), чтобы их новые хуки (hooks) вступили в силу.
Точечная инвалидация
В отличие от полной очистки, этот сценарий делает невалидным один конкретный TranslationBlock или его часть. Это происходит, когда:
Изменяется код в гостевой памяти. Если гостевая программа записывает данные по адресу, где ранее были сгенерированы TB (например, загрузчик), эти TB инвалидируются.
Изменяются атрибуты страницы памяти. Например, при изменении прав доступа к странице (маппинг/анмаппинг в
user-mode) или при сбросе TLB.Установка точки останова (breakpoint). При добавлении брейкпоинта в отладчике (GDB) содержащий этот адрес TB инвалидируется, чтобы при следующем исполнении он был перегенерирован уже с учетом точки останова.
Встроенная статистика
(qemu) info jit [...] gen code size 11411/1073736704 [...] Statistics: TB flush count 0 [...]
Gen code size показывает использование кеша: для простейших примеров вполне ожидаемое значение. TB flush count показывает, сколько раз вызывался flush для TB блоков. Поскольку кеш практически не использован, 0 — вполне ожидаемое значение.
Подробный разбор команды доступен в приложении к этой статье.
Декодирование целевого кода в TCG Ops (на примере RISC-V)
Каждая целевая архитектура предоставляет свою таблицу TranslatorOps:
static const TranslatorOps riscv_tr_ops = { .init_disas_context = riscv_tr_init_disas_context, .tb_start = riscv_tr_tb_start, .insn_start = riscv_tr_insn_start, .translate_insn = riscv_tr_translate_insn, .tb_stop = riscv_tr_tb_stop, };
Процесс трансляции одной инструкции выглядит так:
riscv_tr_init_disas_contextинициализирует контекст — например, копирует информацию о поддерживаемых расширениях ISA изenv -> misa_ext.riscv_tr_insn_startдобавляет специальный TCG OpINDEX_op_insn_start, который сохраняетpcи дополнительную информацию — например, оригинальный opcode. Это нужно для точной отладки и исключений.riscv_tr_translate_insnвызывает декодер: сначала пытается распознать сжатую 16-битную инструкцию, затем 32-битную. Для декодирования используются таблицы, сгенерированные утилитойdecodetree.
Например, для инструкции addi стек вызовов будет таким:
trans_addi() -> gen_arith_imm_fn() -> tcg_gen_addi_i32() -> tcg_gen_add_i32() -> tcg_gen_op3_i32()
В результате рождается TCG Op INDEX_op_add_i32.
После того как все инструкции TB переведены в TCG Ops, вызывается riscv_tr_tb_stop, который обрабатывает случаи DISAS_TOO_MANY (слишком много инструкций в блоке) и DISAS_NORETURN (возникает при исключениях). Для RISC-V функция достаточно простая, а для других архитектур может быть сложнее.

Генерация нативного кода из TCG Ops
Теперь TCG Ops нужно превратить в реальный машинный код хоста. За это отвечает бэкенд, расположенный в tcg/i386/ (для x86), tcg/riscv/ и т. д.
Основная функция - tcg_gen_code(), которая обходит все TCG Ops блока и для каждого вызывает tcg_reg_alloc_op() и затем tcg_out_op(). Например, для INDEX_op_add_i32 на x86 будет сгенерирована инструкция addl.
Пролог и эпилог каждого TB добавляются общей частью QEMU (генератором кода TCG) независимо от целевой архитектуры. Пролог (gen_tb_start) вставляет проверку флага прерывания: если флаг взведен, блок не исполняется, а управление сразу передается в основной цикл. Эпилог (gen_tb_end) выставляет количество выполненных инструкций и создает метки для выхода из блока.

Связи между блоками: цепочки переходов
Если бы после каждого TB мы возвращались в cpu_exec_loop, производительность была бы ниже. Поэтому QEMU пытается строить прямые цепочки блоков: когда TB заканчивается условным или безусловным переходом, он может «перепрыгнуть» сразу на следующий TB, без выхода в основной цикл.
goto_tb и exit_tb
В TCG Ops для этого есть специальные операции: goto_tb n — переход к другому TB (с индексом n). При первом исполнении QEMU запоминает смещение инструкции перехода в jmp_insn_offset[n] и резервирует место для патча. exit_tb — выход из текущей цепочки и возврат в основной цикл. Когда становится известно, куда именно ведет переход — например, после разрешения адреса — вызывается tb_set_jmp_target(), которая патчит машинный код: заменяет заглушку на реальный переход, например, jmp rel32 на x86 или jal zero, offset на RISC-V. Вот как выглядит типичный эпилог блока с двумя ветвями:
brcond_i32 x14/a4, $0x0, eq, $L1 add_i32 pc, pc, $0xc goto_tb $0x1 exit_tb ... set_label $L1 add_i32 pc, pc, $0x1c goto_tb $0x0 exit_tb ...
Ограничения цепочек
Прямые переходы возможны не всегда:
Блоки должны находиться на одной странице памяти (из-за MMU/MPU/PMP).
Нельзя связывать блоки, содержащие атомарные инструкции (требуют глобальной синхронизации потоков).
В режиме отладки (одиночный шаг) формирование цепочек отключается.
Запись в MMIO или вызов helper-функции (например,
wfi,csrr) разрывает цепочку.
lookup_and_goto_ptr
Для косвенных переходов — например, jr t0 — целевой адрес которых не может быть определен на этапе трансляции, QEMU не может использовать прямое связывание через goto_tb. Вместо этого применяется механизм lookup_and_goto_ptr: из сгенерированного кода вызывается вспомогательная функция helper_lookup_tb_ptr, которая ищет TranslationBlock по адресу в кеше и возвращает указатель на его код. После чего выполняется переход на найденный блок. Этот метод не требует возврата в основной цикл cpu_exec_loop, но он все же медленнее, чем прямое связывание, поскольку включает поиск в кеше.
Пример lookup_and_goto_ptr
Рассмотрим реальный случай, когда QEMU применяет lookup_and_goto_ptr. В RISC-V это типично для инструкции jr (или jalr) с адресом, загруженным из памяти. Такой код часто встречается при начальной загрузке (reset vector) или при косвенных вызовах функций.
Возьмем фрагмент из реализации загрузочного вектора RISC-V в QEMU:
; предполагаем, что t0 уже содержит базовый адрес lw a1, 32(t0) ; загружаем значение по смещению 32 lw t0, 24(t0) ; загружаем целевой адрес перехода по смещению 24 jr t0 ; косвенный переходВ данном случае целевой адрес перехода (jr t0) не может быть определён на этапе трансляции, потому что значение t0 загружается из памяти и зависит от текущего состояния гостевой системы.
В данном случае целевой адрес перехода (jr t0) не может быть определён на этапе трансляции, потому что значение t0 загружается из памяти и зависит от текущего состояния гостевой системы.
TCG Ops для этого блока
При трансляции QEMU генерирует следующее промежуточное представление (-d op):
and_i32 tmp6, x5/t0, $0xfffffffe dead: 1 2 pref=0xffff mov_i32 pc, tmp6 sync: 0 dead: 0 1 pref=0xffff call lookup_tb_ptr, $0x6, $1, tmp10, env dead: 1 pref=none goto_ptr tmp10 dead: 0
Пояснения:
and_i32 tmp6, x5/t0, $0xfffffffe— выравнивание адреса (младший бит обнуляется, так как RISC-V требует выравнивания инструкций по четному адресу);mov_i32 pc, tmp6— сохранение вычисленного адреса в счетчик команд гостя;call lookup_tb_ptr— вызов вспомогательной функции, которая по адресуpcищет TranslationBlock в кеше;goto_ptr tmp10— косвенный переход на найденный блок (адрес возвращается вtmp10).
Что происходит во время исполнения:
Нативный код, сгенерированный из этих TCG Ops, загружает значение
t0, уже находящееся в регистре хоста, и обнуляет младший бит.Сохраняет это значение в переменную
pc(частьCPUArchState).-
Вызывает функцию
helper_lookup_tb_ptr, которая:по значению
pcищет TB в хеш-таблице,если блок найден — возвращает указатель на его нативный код,
если не найден — возвращает адрес специальной заглушки (которая вызовет
tb_gen_code).
Выполняется
goto_ptr— переход на полученный адрес.
Этот механизм быстрее, чем полный выход в cpu_exec_loop, но медленнее прямого goto_tb, так как требует поиска в кеше и косвенного вызова.
Почему для одних переходов — goto_tb, а для других — lookup_and_goto_ptr
Рассмотрим две инструкции RISC-V:
jal ra, 0x1000 # прямой переход jr t0 # косвенный переход через регистр
В первом случае целевой адрес (0x1000) закодирован непосредственно в инструкции. QEMU знает его на этапе трансляции, поэтому может использовать goto_tb — зарезервировать место под прямую ветвь в нативном коде и затем «сшить» блоки напрямую, без поиска.
Во втором случае адрес перехода берется из регистра t0, значение которого может меняться динамически. Даже если в данной программе t0 всегда содержит константу, QEMU не отслеживает потоки данных между инструкциями — он видит только jr t0 и не может гарантировать неизменность регистра. Поэтому приходится применять lookup_and_goto_ptr — вызывать вспомогательную функцию для поиска TranslationBlock по адресу в кеше, а затем выполнять косвенный переход.
Таким образом, выбор между goto_tb и lookup_and_goto_ptr определяется не «лучше/хуже», а принципиальной возможностью определить адрес перехода во время трансляции.
Роль setjmp/longjmp в TCG
Внутри QEMU активно используется пара setjmp/longjmp (точнее, sigsetjmp/siglongjmp).
Когда случается исключение — например, деление на ноль — страница памяти становится недоступной или поступает прерывание, обработка должна произойти в основном цикле, а не внутри сгенерированного кода. Но сгенерированный код может находиться на любой глубине стека (вызовы helper-функций, цепочки TB). Вместо того чтобы раскручивать стек вручную, QEMU устанавливает точку возврата с помощью setjmp в cpu_exec() и в случае исключения вызывает longjmp, прыгая прямо в начало цикла. Это быстро и надежно.
Основной принцип:
setjmpсохраняет текущее состояние стека и контекста процессора в специальной структуре (jmp_env). Функцияlongjmp(илиsiglongjmp) затем восстанавливает это сохраненное состояние, выполняя так называемый non-local goto. Это позволяет передать управление не просто в другую функцию, а в другую, более раннюю точку выполнения на стеке, минуя обычный возврат из функций.Ключевое место установки. В главном цикле эмуляции, в функции
cpu_exec, перед началом выполнения кода устанавливается точка возврата с помощьюsigsetjmp. Этот вызов оборачивает весь последующий процесс исполнения, работая как глобальный защитный блок для обработки любых исключений.Инициирование перехода (
longjmp): Если во время выполнения нативного кода происходит сбой — например, деление на ноль, ошибка доступа к памяти или просто вызов вспомогательной функцииcpu_loop_exit()— QEMU инициирует переход к сохраненной точке. Этот переход может быть выполнен прямо из обработчика сигнала хоста (SIGSEGV, SIGBUS и т. д.) или из любого места в коде ядра эмуляции.Атомарные операции. Существует особый сценарий — атомарные инструкции гостя. Для них QEMU может использовать отдельный, «безопасный» контекст
setjmp. Это гарантирует, что эмулятор корректно обработает данное событие даже при возникновении прерывания или исключения во время выполнения атомарной операции.
Подводные камни и особенности:
Проблема оптимизации компилятора. Компилятор может посчитать, что после вызова
longjmp, помеченного какnoreturn-функция, локальные переменные больше не нужны и «испортить» их. Из-за этого при возврате изsetjmpнекоторые переменные могут указывать на неверные данные. Решается это принудительной перезагрузкой переменных из глобального хранилища после выполненияlongjmp.Многопоточность (MTTCG). В многопоточном режиме эмуляции использование
longjmpтребует особой осторожности. Необходимо следить, чтобыlongjmpне «выпрыгнул» из критической секции, оставив захваченные мьютексы в заблокированном состоянии. Это может привести к взаимоблокировкам (deadlock) в эмуляторе.
Пролог и эпилог TranslationBlock
Каждый TranslationBlock (TB) в QEMU обрамляется прологом и эпилогом. Эти фрагменты кода генерируются общей частью TCG (функции gen_tb_start() и gen_tb_end()) и не зависят от конкретной целевой архитектуры. Они обеспечивают два критически важных механизма: реакцию на прерывания и точный учет выполненных инструкций для режима icount.
Пролог: вход в блок
Перед исполнением переведенных инструкций гостя TCG вставляет пролог. Его главная задача — проверить, не было ли запрошено прерывание (или исключение) до выполнения блока. Это достигается с помощью проверки счётчика CPUState->neg->icount_decr. Пролог проверяет, не был ли выставлен запрос на прерывание или выход. Если счетчик принял отрицательное значение, выполнение текущего TB отменяется и управление немедленно возвращается в cpu_exec_loop. Этот механизм необходим для:
Детерминированности выполнения. В режиме точного подсчета инструкций (
-icount) это гарантирует, что операции ввода-вывода и, как следствие, обработка прерываний происходят в строго определенные моменты (на границах TB), обеспечивая воспроизводимость результатов эмуляции.Корректной работы в многопоточных сценариях. Флаг служит механизмом синхронизации и позволяет безопасно прервать выполнение кода в одном потоке, когда другой поток или обработчик сигнала изменяет глобальное состояние TCG, предотвращая состояние гонки и сбои.
Пример пролога:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-mem -d tb_stats:all,op_opt -dfilter 0x800001a0+0x4 -nographic -serial mon:stdio OP after optimization and liveness analysis: ld_i32 tmp1,env,$0xffffffffffffffec pref=0xffff brcond_i32 tmp1,$0x0,lt,$L0 dead: 0 ld_i64 tmp4,$0x7fc99401ec38,$0x0 pref=0xffff add_i64 tmp4,tmp4,$0x1 dead: 1 2 pref=0xffff st_i64 tmp4,$0x7fc99401ec38,$0x0 dead: 0 1 st8_i32 $0x0,env,$0xfffffffffffffff0 dead: 0
Ld_i32 tmp1,env,$0xffffffffffffffec адрес нашего icount_decr в CPUState, а brcond_i32 tmp1,$0x0,lt,$L0 не что иное, как проверка на отрицательное значение.
На самом деле подробности механизма выхода из TB не столь важны. А важно, что это служит способом выхода, когда блоки связаны и исполняются напрямую, друг за другом.
Эпилог: выход из блока
Когда трансляция всех инструкций блока завершена, TCG добавляет эпилог. Он выполняет финальные действия после того, как сгенерированный нативный код отработал.
Обновление счетчика инструкций. В режиме -icount эпилог прибавляет количество выполненных в блоке гостевых инструкций к глобальному счетчику. Это нужно для эмуляции временных задержек и корректной обработки прерываний по инструкциям.
Создание точек выхода. Эпилог резервирует места для инструкций goto_tb и exit_tb. Если блок завершается переходом на известный адрес, то размещенная в эпилоге заглушка будет позже заменена прямым переходом на следующий TB. Если переход неизвестен или требуется вернуться в основной цикл, тогда эпилог завершится на вызове exit_tb. Пример эпилога с двумя прямыми переходами:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-mem -d tb_stats:all,op_opt -dfilter 0x800001a0+0x4 -nographic -serial mon:stdio [...] ---- 00000000000001a8 0000000000000000 0000000000000000 brcond_i32 x6/t1,x7/t2,lt,$L1 dead: 0 1 add_i32 pc,pc,$0xc sync: 0 dead: 0 1 2 pref=0xffff goto_tb $0x1 exit_tb $0x7f8e75cfefc1 set_label $L1 add_i32 pc,pc,$0xfffffff4 sync: 0 dead: 0 1 2 pref=0xffff goto_tb $0x0 exit_tb $0x7f8e75cfefc0 set_label $L0 exit_tb $0x7f8e75cfefc3 [...]
Практические примеры
Бесконечный цикл с двумя ветвлениями
Теперь подробнее рассмотрим пример с бесконечным циклом и двумя ветвлениями:
Инкрементируем a3.
Если a3 четное, идем в loop2. Если a3 нечетное, идем в loop1.
В каждом из loop1, loop2 крутим a1 — в loop1 от 100 до 0, loop2 от 0 до 100.
8000017c <test_loop>: 8000017c: 00168693 add a3,a3,1 80000180: 0016f713 and a4,a3,1 80000184: 00070a63 beqz a4,80000198 <loop2> # Нечетные 80000188 <loop1>: 80000188: 06400593 li a1,100 8000018c: fff58593 add a1,a1,-1 80000190: fe059ee3 bnez a1,8000018c <loop1+0x4> 80000194: fe9ff06f j 8000017c <test_loop> # Четные 80000198 <loop2>: 80000198: 00000593 li a1,0 8000019c: 06400613 li a2,100 800001a0: 00158593 add a1,a1,1 800001a4: fec59ee3 bne a1,a2,800001a0 <loop2+0x8> 800001a8: fd5ff06f j 8000017c <test_loop>
Запустим QEMU и выведем список блоков, сортированных по физическому значению PC:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-loop -d tb_stats:all -nographic -serial mon:stdio QEMU 11.0.50 monitor - type 'help' for more information (qemu) stop (qemu) info tb-list phys_pc [...] TB id:20 | phys:0x17c virt=0 flags:0x01014000 invalid:0/1 | exec:34978754/0 coverage:0.65% | trans:1 inst: g:3 op:24 op_opt:22 spills:0 | h/g (host bytes / guest insts): 45.333333 TB id:21 | phys:0x188 virt=0 flags:0x01014000 invalid:0/1 | exec:17489378/0 coverage:0.33% | trans:1 inst: g:3 op:24 op_opt:16 spills:0 | h/g (host bytes / guest insts): 29.333333 TB id:22 | phys:0x18c virt=0 flags:0x01014000 invalid:0/1 | exec:1731448363/0 coverage:48.41% | trans:1 inst: g:2 op:21 op_opt:19 spills:0 | h/g (host bytes / guest insts): 58.000000 TB id:23 | phys:0x194 virt=0 flags:0x01014000 invalid:0/1 | exec:17489377/0 coverage:0.98% | trans:1 inst: g:1 op:14 op_opt:12 spills:0 | h/g (host bytes / guest insts): 76.000000 TB id:24 | phys:0x198 virt=0 flags:0x01014000 invalid:0/1 | exec:17489377/0 coverage:0.24% | trans:1 inst: g:4 op:27 op_opt:18 spills:0 | h/g (host bytes / guest insts): 24.000000 TB id:25 | phys:0x1a0 virt=0 flags:0x01014000 invalid:0/1 | exec:1731448323/0 coverage:48.41% | trans:1 inst: g:2 op:22 op_opt:19 spills:0 | h/g (host bytes / guest insts): 60.000000 TB id:26 | phys:0x1a8 virt=0 flags:0x01014000 invalid:0/1 | exec:17489377/0 coverage:0.98% | trans:1 inst: g:1 op:14 op_opt:12 spills:0 | h/g (host bytes / guest insts): 76.000000 [...]
Находим id блока, который начинается с адреса 0x17c, что соотвествует 8000017c , и выводим его граф:
(qemu) info tb-cfg 20 CFG dumped: /tmp/qemu-cfg-tb-20-4096.dotПолучаем симпатичный граф в формате dot. В консоли можно сразу выполнить xdot /tmp/qemu-cfg-tb-20-4096.dot.
Получаем симпатичный граф в формате dot. В консоли можно сразу выполнить xdot /tmp/qemu-cfg-tb-20-4096.dot.

Здесь мы с вами видим исключительно прямые переходы. Значит, как я уже говорил ранее, QEMU исполняет этот код без выхода в основной цикл.
Логика связывания очень проста:
static int __attribute__((noinline)) cpu_exec_loop(CPUState *cpu, SyncClocks *sc) { [...] while (!cpu_handle_exception(cpu, &ret)) { TranslationBlock *last_tb = NULL; int tb_exit = 0; while (!cpu_handle_interrupt(cpu, &last_tb)) { [...] /* See if we can patch the calling TB. */ if (last_tb) { tb_add_jump(last_tb, tb_exit, tb); } [...]
Если блоки исполнялись подряд - не было:
исключений,
прерываний,
операций с MMIO,
специальных инструкций.
Тогда появляется прямая связь между блоками.
Работа с памятью и wfi
Теперь рассмотрим код, который работает с оперативной памятью в QEMU:
Помещаем адрес test_array в регистр t0.
Девять раз записываем в массив значение t1, инкрементируя его на каждом шаге.
В конце исполняем специальную инструкцию Wait For Interrupt (wfi).
80000184 <store_array_addr>: 80000184: 00003297 auipc t0,0x3 80000188: e7c28293 addi t0,t0,-388 # 80003000 <test_array> 8000018c: 00000313 li t1,0 80000190: 00a00393 li t2,10 80000194 <write_loop>: 80000194: 0062a023 sw t1,0(t0) 80000198: 0002ae03 lw t3,0(t0) 8000019c: 01c31863 bne t1,t3,800001ac <end_loop> 800001a0: 00130313 addi t1,t1,1 800001a4: 00428293 addi t0,t0,4 800001a8: fe7346e3 blt t1,t2,80000194 <write_loop> 800001ac <end_loop>: 800001ac: 00100193 li gp,1 800001b0: 0ff0000f fence 800001b4: 10500073 wfi 800001b8: 02301063 bne zero,gp,800001d8 <pass>
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-mem -d tb_stats:all -nographic -serial mon:stdio QEMU 11.0.50 monitor - type 'help' for more information (qemu) info tb-list phys_pc [...] TB id:20 | phys:0x184 virt=0 flags:0x01c14003 invalid:0/1 | exec:1/0 coverage:0.71% | trans:1 inst: g:7 op:35 op_opt:30 spills:0 | h/g (host bytes / guest insts): 46.857143 TB id:21 | phys:0x194 virt=0 flags:0x01c14003 invalid:0/1 | exec:9/0 coverage:14.99% | trans:1 inst: g:3 op:27 op_opt:22 spills:0 | h/g (host bytes / guest insts): 101.333333 TB id:22 | phys:0x1a0 virt=0 flags:0x01c14003 invalid:0/1 | exec:10/0 coverage:16.66% | trans:1 inst: g:3 op:25 op_opt:22 spills:0 | h/g (host bytes / guest insts): 48.000000 TB id:23 | phys:0x1ac virt=0 flags:0x01c14003 invalid:0/1 | exec:1/0 coverage:1.25% | trans:1 inst: g:4 op:27 op_opt:25 spills:0 | h/g (host bytes / guest insts): 42.000000 [...]
Находим id блока, что начинается с адреса 0x184, который соотвествует 80000184 <store_array_addr>, и выводим его граф:
(qemu) info tb-cfg 20 CFG dumped: /tmp/qemu-cfg-tb-29-4096.dot

Как мы видим, при работе с памятью блоки также получаются неразрывными, а связь формируется, даже если блок выполнялся один раз, как блок TB 3 в нашем случае.
Инструкция wfi позволяет нам в данном случае спокойно дождаться завершения цикла. После отработки цикла vCPU по данной команде переходит в режим ожидания прерывания. В этом мы можем убедиться сами:
(qemu) info registers CPU#0 priv M V 0 pc 800001b8 [...]
Этот пример несильно отличается от предыдущего. Он приведен для контраста и демонстрации разницы между операциями над памятью и областями, помеченными как MMIO, как это будет в следующем примере.
Вывод Hello World в UART
Рассмотрим, как поведет себя QEMU при записи в MMIO-область UART. Пусть есть код, который пишет байт в регистр данных UART:
80000184: 00002517 auipc a0,0x2 80000188: e7c50513 addi a0,a0,-388 # 80002000 <hello_str> 8000018c: 100005b7 lui a1,0x10000 80000190 <putchar_loop>: 80000190: 00054603 lbu a2,0(a0) 80000194: 02060663 beqz a2,800001c0 <done> 80000198: 100006b7 lui a3,0x10000 8000019c: 00568693 addi a3,a3,5 # 10000005 <_start-0x6ffffffb> 800001a0 <wait_tx>: 800001a0: 0006c703 lbu a4,0(a3) 800001a4: 02077713 andi a4,a4,32 800001a8: fe070ce3 beqz a4,800001a0 <wait_tx> 800001ac: 100006b7 lui a3,0x10000 800001b0: 00068693 mv a3,a3 800001b4: 00c68023 sb a2,0(a3) # 10000000 <_start-0x70000000> 800001b8: 00150513 addi a0,a0,1 800001bc: fd5ff06f j 80000190 <putchar_loop> 800001c0 <done>: 800001c0: 0ff0000f fence 800001c4: 10500073 wfi 800001c8: 02301063 bne zero,gp,800001e8 <pass>
В приведенном коде две инструкции работают непосредственно с MMIO областью:
lbu a4,0(a3)— проверяется, готов ли UART к отправке следующего байта;sb a2,0(a3)— непосредственно запись байта, прочитанного из массива, в приемник UART.
Обе инструкции ведут себя схожим образом: исполнение блока прерывается. Как мы уже говорили ранее, это обусловлено тем, что запись/чтение в MMIO-область может вызывать изменение состояния машины целиком — например, генерация прерывания.
Рассмотрим следующие диаграммы:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-uart -d tb_stats:all -nographic -serial mon:stdio Hello World! QEMU 11.0.50 monitor - type 'help' for more information (qemu) info tb-list phys_pc [...] TB id:22 | phys:0x198 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:4.03% | trans:1 inst: g:5 op:29 op_opt:25 spills:0 | h/g (host bytes / guest insts): 43.200000 TB id:23 | phys:0x1a0 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:20.15% | trans:1 inst: g:1 op:11 op_opt:9 spills:0 | h/g (host bytes / guest insts): 136.000000 TB id:24 | phys:0x1a4 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:10.08% | trans:1 inst: g:2 op:22 op_opt:20 spills:0 | h/g (host bytes / guest insts): 64.000000 TB id:25 | phys:0x1ac virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:4.03% | trans:1 inst: g:5 op:23 op_opt:20 spills:0 | h/g (host bytes / guest insts): 36.800000 TB id:26 | phys:0x1b4 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:20.15% | trans:1 inst: g:1 op:11 op_opt:9 spills:0 | h/g (host bytes / guest insts): 144.000000 TB id:27 | phys:0x1b8 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:10.08% | trans:1 inst: g:2 op:17 op_opt:15 spills:0 | h/g (host bytes / guest insts): 44.000000 TB id:28 | phys:0x1c0 virt=0 flags:0x01c14003 invalid:0/1 | exec:1/0 coverage:0.52% | trans:1 inst: g:3 op:25 op_opt:23 spills:0 | h/g (host bytes / guest insts): 53.333333 [...] (qemu) info tb-cfg 22 disasm 1 CFG dumped: /tmp/qemu-cfg-tb-22-1.dot (qemu) info tb-cfg 23 disasm 3 CFG dumped: /tmp/qemu-cfg-tb-23-3.dot
Так выглядит сформированной блок для pc 0x198, так как при трансляции этого участка QEMU не знает заранее (хотя потенциально может), что адрес 0x10000000 относится к MMIO, а не к обычной памяти.

Как несложно заметить, прямых связей он не имеет, так как на самом деле он никогда не будет исполнен целиком от и до. Его выполнение завершается, когда QEMU пытается выполнить lbu a4,0(a3). Как только он определяет, что данная инструкция относится к операциям с MMIO областью, выполнение блока завершается ДО выполнения данной инструкции, а сама инструкция выделяется в отдельный блок pc 0x1a0:

Блок MMIO довольно медленный и дорогой в плане количества инструкций хоста на одну инструкцию гостя:
TB id:23 | phys:0x1a0 virt=0 flags:0x01c14003 invalid:0/1 | exec:13/0 coverage:20.15% | trans:1 inst: g:1 op:11 op_opt:9 spills:0 | h/g (host bytes / guest insts): 136.000000
По многим причинам, что забавно, блок коррелирует с реальным железом.
Фактически для обслуживания одной такой гостевой инструкции мы выполняем следующий код:
OUT: [size=136] -- guest addr 0x00000000000001a0 + tb prologue 0x7f2bae27f1c0: 48 8d 1d 91 fb d9 f1 leaq -0xe26046f(%rip), %rbx 0x7f2bae27f1c7: 4c 8b 23 movq (%rbx), %r12 0x7f2bae27f1ca: 49 ff c4 incq %r12 0x7f2bae27f1cd: 4c 89 23 movq %r12, (%rbx) 0x7f2bae27f1d0: c6 45 f0 01 movb $1, -0x10(%rbp) 0x7f2bae27f1d4: 8b 5d 68 movl 0x68(%rbp), %ebx 0x7f2bae27f1d7: 48 8b fb movq %rbx, %rdi 0x7f2bae27f1da: 48 c1 ef 07 shrq $7, %rdi 0x7f2bae27f1de: 23 7d 90 andl -0x70(%rbp), %edi 0x7f2bae27f1e1: 48 03 7d 98 addq -0x68(%rbp), %rdi 0x7f2bae27f1e5: 8b f3 movl %ebx, %esi 0x7f2bae27f1e7: 81 e6 00 f0 ff ff andl $0xfffff000, %esi 0x7f2bae27f1ed: 3b 37 cmpl (%rdi), %esi 0x7f2bae27f1ef: 0f 85 2b 00 00 00 jne 0x7f2bae27f220 0x7f2bae27f1f5: 48 8b 7f 18 movq 0x18(%rdi), %rdi 0x7f2bae27f1f9: 0f b6 1c 3b movzbl (%rbx, %rdi), %ebx 0x7f2bae27f1fd: 89 5d 70 movl %ebx, 0x70(%rbp) 0x7f2bae27f200: 8b 9d 18 12 00 00 movl 0x1218(%rbp), %ebx 0x7f2bae27f206: 83 c3 04 addl $4, %ebx 0x7f2bae27f209: 89 9d 18 12 00 00 movl %ebx, 0x1218(%rbp) 0x7f2bae27f20f: e9 00 00 00 00 jmp 0x7f2bae27f214 0x7f2bae27f214: 48 8d 05 e5 fe ff ff leaq -0x11b(%rip), %rax 0x7f2bae27f21b: e9 f8 dd ff ff jmp 0x7f2bae27d018 -- tb slow paths + alignment 0x7f2bae27f220: 8b f3 movl %ebx, %esi 0x7f2bae27f222: 48 8b fd movq %rbp, %rdi 0x7f2bae27f225: ba 03 5c 01 00 movl $0x15c03, %edx 0x7f2bae27f22a: 48 8d 0d cc ff ff ff leaq -0x34(%rip), %rcx 0x7f2bae27f231: ff 15 09 00 00 00 callq *9(%rip) 0x7f2bae27f237: 8b d8 movl %eax, %ebx 0x7f2bae27f239: e9 bf ff ff ff jmp 0x7f2bae27f1fd 0x7f2bae27f23e: 90 nop 0x7f2bae27f23f: 90 nop data: [size=8] 0x7f2bae27f240: .quad 0x000055c4441ba096
Если рассмотреть ситуацию более подробно, то, когда CPU пытается выполнить sb или lbu, обнаруживается, что страница помечена как MMIO. Cрабатывает механизм, который:
Прерывает выполнение текущего блока.
Сохраняет состояние (включая
pc).Возвращает управление в
cpu_exec_loopчерезlongjmp.В основном цикле специальным образом обрабатывается MMIO-запись: выполняется запрос к модели устройства UART.
Исполнение продолжается с того же
pc(инструкцииsbилиlbu), но уже в новом TB, состоящем из единственной инструкции.
Это видно на графе переходов: изначальный блок, содержащий MMIO-инструкцию, не имеет прямой связи со следующим блоком. Вместо этого идет разрыв, обозначенный оранжевой стрелкой, а потом отдельный маленький TB с адресом, что уже содержится в предыдущем блоке (0x1a0), и следующий за ним блок с «остатками» инструкций по адресам 0x1a4 и 0x1a8:

Сам граф восстановлен с помощью информации в cpu_io_recompile(), которая всегда вызывается при обнаружении MMIO-операций.
TCG statistics (приложение)
Базовая статистика по TCG включена в ванильную версию QEMU.
Команда info jit в мониторе QEMU дает полную картину состояния JIT-компилятора TCG: размер кеша, характеристики сгенерированных блоков, эффективность хеширования и статистику событий управления памятью. Рассмотрим каждую секцию подробно для примера virt32-pt-uart:
(qemu) info jit Accelerator settings: one-insn-per-tb: off Translation buffer state: gen code size 11411/1073736704 TB count 31 TB avg target size 13 max=128 bytes TB avg host size 128 bytes (expansion ratio: 9.8) cross page TB count 0 (0%) direct jump count 13 (41%) (2 jumps=6 19%) TB hash buckets 31/8192 (0.38% head buckets used) TB hash occupancy 0.09% avg chain occ. Histogram: [0.0,2.5)%|█ ▁|[22.5,25.0]% TB hash avg chain 1.000 buckets. Histogram: 1|█|1 Statistics: TB flush count 0 TB invalidate count 0 TLB full flushes 0 TLB partial flushes 2 TLB elided flushes 590
Настройки TCG
One-insn-per-tb — флаг, который форсирует генерацию блоков ровно из одной гостевой инструкции. Отключен, что означает: QEMU формирует блоки из последовательности инструкций до естественной границы: ветвление, переход, MMIO, лимит инструкций. Включить данный режим можно параметром:
-accel=tcg,one-insn-per-tb=on|off (one guest instruction per TCG translation block)
Состояние буфера трансляции
Размер буфера кода
gen code size — занято 11 411 Б из общего буфера в 1 073 736 704 Б (1 ГБ). Использование ничтожно мало, меньше 0.001%. Это означает, что кеш практически пуст, что логично, так как у нас всего 31 TranslationBlock.
Количество и размеры TB
TB count — всего 31 активный TranslationBlock.
TB avg target size — средний размер гостевого кода внутри блока: 13 байт. Для RISC-V это примерно 3–4 инструкции по 4 байта или одна двухбайтная сжатая и несколько обычных. Максимальный размер — 128 Б (до 32 инструкций). Это говорит о том, что блоки обрезаются ветвлениями, MMIO-обращениями или лимитом инструкций (но здесь лимит не достигнут).
TB avg host size — средний размер сгенерированного машинного кода хоста: 128 байт.
Expansion ratio — коэффициент расширения (host / guest): 9.8. Это высокий показатель, типичный для эмуляции с MMIO, когда для каждой инструкции генерируется много проверок TLB, вызовы helper-функций и обработка исключений. Для сравнения: в блоках без MMIO коэффициент обычно 3–5.
Переходы между страницами
cross page TB count — ни один TB не пересекает границу страницы гостевой памяти. Это значит, что блоки всегда умещаются в пределах одной страницы и QEMU не обрезал их по этой причине. Обычно это благоприятно для производительности, так как не требуется дополнительная обработка пересечений.
Прямые переходы
direct jump count — общее количество прямых переходов (goto_tb), связывающих TB в цепочки. Их 13 из 31 блока, что составляет 41%.
(2 jumps=6 19%) — из этих 13 переходов 6 являются парными, по два перехода на блок, что характерно для условных ветвлений — например, beq/bne. Это составляет 19% от общего числа переходов: 6/31 ≈ 19%.
Вывод: почти половина блоков участвует в прямых цепочках, остальные завершаются exit_tb (возврат в основной цикл). Причины — MMIO-обращения, косвенные переходы или вызовы helper-функций.
Состояние хеш-таблицы TB
TB hash buckets — хеш-таблица содержит 8192 корзины, из которых занято только 31 (0.38%). Это означает, что почти каждая занятая корзина содержит ровно один TB, то есть коллизий практически нет.
TB hash occupancy — средняя длина цепочки в занятых корзинах составляет всего 0.09% от максимальной. Гистограмма показывает, что распределение крайне неравномерное: большинство корзин пустые, лишь несколько содержат по одному элементу.
TB hash avg chain — средняя длина цепочки в занятых корзинах равна 1.000, гистограмма подтверждает, что все цепочки имеют длину 1. Это идеальный случай для быстрого поиска TB по адресу.
Статистика
Сбросы и инвалидации TB
TB flush count = 0 — ни разу не вызывалась tb_flush(), полная очистка всего кеша. Это логично, так как кеш практически пуст: занято всего 11 кБ из 1 ГБ.
TB invalidate count = 0 — ни один TB не был точечно инвалидирован. Это означает, что гостевой код не изменялся динамически (нет самодинамического кода, перезаписей страниц и т. п.). Все блоки остаются валидными на протяжении всего сеанса.
Сбросы TLB (Translation Lookaside Buffer)
TLB full flushes = 0 — полный сброс TLB не происходил.
TLB partial flushes = 2 — дважды происходил частичный сброс TLB. Это может быть вызвано изменением маппинга страниц (например, при загрузке ELF-файла или переключении контекста). В данном случае это, скорее всего, произошло при инициализации теста.
TLB elided flushes = 590 — это означает, что QEMU пропустил 590 потенциальных сбросов TLB, потому что они были признаны необязательными: например, при работе с памятью без MMU или когда права доступа не изменились. Это говорит о том, что эмуляция работает в режиме с минимальными накладными расходами на управление памятью, что характерно для систем без MMIO и защиты памяти.
TCG code quality tracking (приложение)
Для глубокого анализа работы TCG — оценки эффективности трансляции, поиска «горячих» блоков, изучения связей между TranslationBlock — в QEMU существует набор инструментов, собранный в серии патчей TCG code quality tracking (изначально предложен Vanderson M. do Rosario, затем доработан мной). Эти патчи добавляют:
Сбор статистики по каждому TB: количество исполнений, время трансляции, количество спиллов (spills), отношение размера нативного кода к числу гостевых инструкций.
Команды монитора для вывода и фильтрации статистики.
Генерацию графа связей блоков в формате DOT (Graphviz) для визуализации цепочек переходов.
Изначально появилась в виде развития идей для QEMU в рамках Internships/ProjectIdeas. Была выпущена первая серия патчей, к сожалению, до победного конца она не дошла. Затем позже была вторая попытка, также не дошедшая до финала.
Сборка с поддержкой трекинга
Используйте ветку nshubin/tcg-code-quality репозитория qemu-jit-playground:
$ git clone https://gitflic.ru/project/maquefel/qemu-jit-playground $ cd qemu-jit-playground $ git submodule update --init --recursive $ mkdir build-qemu && cd build-qemu $ ../qemu/configure --target-list="riscv32-softmmu,riscv64-softmmu" \ --enable-debug --enable-debug-tcg $ make -j$(nproc)
Запуск с включенной статистикой:
$ build-qemu/qemu-system-riscv32 -M virt -bios riscv-tests/isa/rv32ui-p-add \ -nographic -serial mon:stdio -d tb_stats:all
Ключ -d tb_stats:all активирует сбор всей статистики. Можно указать tb_stats:exec (только частоту исполнения) или tb_stats:trans (детали трансляции). Собранная статистика сохраняется на протяжении всего сеанса эмуляции.
Команды монитора
После запуска QEMU с опцией -monitor stdio (или -serial mon:stdio) и перехода в монитор (Ctrl+A, затем C) доступны команды.
info tb-list — список всех TB в кеше.
(qemu) info tb-list TB id:0 | phys:0x13c virt=0 flags:0x04014000 invalid:0/1 | exec:31601138481/0 coverage:45.52% | trans:1 inst: g:2 op:22 op_opt:19 spills:0 | h/g (host bytes / guest insts): 60.000000 TB id:1 | phys:0x130 virt=0 flags:0x04014000 invalid:0/1 | exec:31600885041/0 coverage:45.52% | trans:1 inst: g:2 op:21 op_opt:19 spills:0 | h/g (host bytes / guest insts): 58.000000 ...
Поля:
exec: normal/atomic— сколько раз блок был исполнен (обычно и атомарно);coverage— доля от общего числа исполнений.trans:1 inst: g:2 op:22 op_opt:19 spills:0— количество трансляций блока, число гостевых инструкций (g), число TCG Ops до (op) и после оптимизации (op_opt), число спиллов (сохранений регистров на стек).h/g- отношение размера сгенерированного нативного кода (в байтах) к числу гостевых инструкций. Чем ниже, тем эффективнее трансляция.
Сортировка по различным критериям:
(qemu) info tb-list hotness # самые горячих блоков (qemu) info tb-list hg 5 # 5 блоков с лучшим h/g (qemu) info tb-list phys_pc # сортированы по физическому адресу выполнения
info tb — детали одного TB
(qemu) info tb 5 TB id:5 | phys:0x120 virt=0 flags:0x04014000 invalid:0/1 | exec:736465197/0 coverage:0.46% | trans:1 inst: g:4 op:29 op_opt:24 spills:0 | h/g (host bytes / guest insts): 74.000000 0x00000120: 001006b7 lui a3,256 0x00000124: 428c lw a1,0(a3) 0x00000126: 4290 lw a2,0(a3) 0x00000128: 00b65763 ble a1,a2,14 # 0x136
Команда показывает дизассемблированный листинг исходного кода гостя, который извлекается из образа памяти QEMU.
info tb-cfg [format] [depth] — граф связей TB
Генерирует файл в формате DOT, описывающий граф переходов от указанного Translation Block (TB) к соседним на глубину depth (по умолчанию 4096). Параметр format управляет содержимым узлов графа. Поддерживаются следующие форматы:
basic — только идентификатор TB и физический адрес PC;
exec — добавляет счетчики исполнений (нормальные, атомарные, перекомпиляции) и процент покрытия;
jit — статистика JIT: число гостевых инструкций, количество TCG-операций до и после оптимизации, число spill-ов, размер выходного кода;
full — все доступные поля, кроме дизассемблера; disasm — basic плюс дизассемблированный код (используется по умолчанию, если формат не указан).
Примеры использования:
(qemu) info tb-cfg 22 # формат disasm, глубина 3 (qemu) info tb-cfg 22 exec 5 # exec-статистика, глубина 5 (qemu) info tb-cfg 22 basic # только базовая информация, глубина 3
Вывод команды:
CFG dumped: /tmp/qemu-cfg-tb-22-3.dot
Содержимое .dot-файла упрощенно:
digraph G { node_0 [shape="record" label="TB 0\n-------------\nPC: 0x120\nexec count: 736465197\n\n lui a3,256\\l lw a1,0(a3)\\l lw a2,0(a3)\\l ble a1,a2,14\\l"]; node_1 [shape="record" label="TB 1 ..."]; node_0 -> node_1; }
Преобразовать в картинку можно командой:
$ dot -Tpng /tmp/qemu-cfg-tb-22-3.dot -o graph.png
Или посмотреть сразу с помощью xdot:
$ xdot /tmp/qemu-cfg-tb-22-3.dot
изуализация помогает понять, где QEMU удаётся сшить блоки в прямые цепочки, а где цепочка рвется: MMIO, межстраничные переходы, вызовы helpers. Каждый узел графа содержит информацию в выбранном формате, а стрелки показывают фактические переходы между TB. Оранжевые ребра при наличии обозначают перекомпиляцию блока из-за MMIO-доступа.
Пример содержимого DOT-файла для формата disasm (сокращенно):
digraph G { node_0 [shape="record" label="TB 0\nPC: 0x120\nexec count: 736465197\n-------------\nlui a3,256\lw a1,0(a3)\lw a2,0(a3)\lble a1,a2,14\l"]; node_1 [shape="record" label="TB 1\nPC: 0x140\nexec count: 12\n-------------\naddi a3,a3,1\lj 0x120\l"]; node_0 -> node_1; }
Пример анализа
Запустим тест virt32-pt-loop, содержащий бесконечный цикл с условными переходами:
$ ./qemu-system-riscv32 -M virt -bios riscv-tests/qemu/virt32-pt-loop -nographic -d tb_stats:all
После некоторого времени выполнения в мониторе:
(qemu) info tb-list hotness 5 TB id:0 | phys:0x13c exec:31601138481 h/g:60.00 TB id:1 | phys:0x130 exec:31600885041 h/g:58.00 TB id:2 | phys:0x126 exec:1276808556 h/g:128.00 ...
Блоки с phys_pc=0x13c и 0x130 самые горячие: исполняются миллиарды раз. Их h/g около 60, что довольно эффективно. Блок id=2 имеет h/g=128, что хуже: возможно, он содержит сложные операции или переходы, которые не удалось оптимизировать.
Команда info tb-cfg 0 построит граф вокруг самого горячего блока, показывая, как он связан с соседними.
Назначение и возможности
Оценка качества трансляции: низкое
h/gи малое число спиллов говорят об эффективном использовании регистров хоста.Поиск узких мест: блоки с аномально высоким
h/gили частыми вытеснениями (spills) — кандидаты на ручную оптимизацию декодера или добавление новых TCG Ops.Визуализация связей: позволяет увидеть, где QEMU применяет
goto_tb, а где цепочка прерывается — например, на MMIO или межстраничных переходах. Это важно для понимания реального потока управления в эмуляции.
К сожалению, серия патчей не была принята в основной QEMU: требует доработки и обсуждения. Однако код доступен в репозитории qemu-jit-playground и может быть использован для исследовательских целей, отладки и обучения.
Что осталось за кадром
Чтобы не делать статью еще больше, мы намеренно не рассмотрели:
многопоточный TCG (MTTCG) — каждый vCPU в отдельном потоке хоста, синхронизация атомарных операций;
кеширование и вытеснение TranslationBlock (работа с
tb_ctx);режим точного подсчёта инструкций (
-icount);реализацию helper-функций (например,
helper_wfi,helper_csrr);TCG Plugins — механизм для инструментирования гостевого кода.
Выводы
QEMU TCG — это JIT-компилятор, транслирующий машинный код одной архитектуры в другую.
Основная единица трансляции — TranslationBlock (TB), который всегда заканчивается на ветвлении, MMIO, специнструкции или границе страницы.
Промежуточное представление, TCG Op, оптимизируется: упрощаются константы, удаляется мертвый код. Затем преобразуется в нативный код.
Для ускорения QEMU строит цепочки прямых переходов между TB (
goto_tb)и патчит машинный код «на лету».Исключения и прерывания обрабатываются через
setjmp/longjmp:это позволяет выпрыгнуть из любого места сгенерированного кода.Отладка трансляции поддерживается ключами
-d in_asm,op,op_opt,out_asmи фильтрацией по адресам.
TCG — это пример элегантного инженерного решения, которое, несмотря на кажущуюся простоту (трансляция кода в код), обросло множеством тонкостей. Если вы хотите глубже изучить тему, рекомендую обратить внимание на материалы из репозитория-обертки.
Благодарность
Статья подготовлена на основе моей лекции в рамках курса базовой кафедры YADRO в МФТИ. Благодарю Игоря Гурьянова, Максима Кочеткова, Владимира Исаева и Сергея Мирошниченко за помощь в подготовке самой первой лекции (если кого забыл, то я не со зла, все-таки два с половиной года прошло...). А также благодарю Илью Чичкова за помощь в подготовке этой статьи и вопросы по лекциям.
Если вы умеете и хотите работать с QEMU, взгляните на наши вакансии:
Глоссарий
IR (Intermediate Representation) — промежуточное представление кода в QEMU TCG. Унифицированный набор операций (TCG Ops), получаемый после декодирования машинных инструкций гостя и используемый для последующей генерации нативного кода хоста.
KVM (Kernel Virtual Machine) — модуль ядра Linux, обеспечивающий аппаратную виртуализацию. QEMU в режиме KVM выступает в роли настройщика и обработчика исключений (например,
KVM_EXITпри обращении к MMIO), а выполнение гостевого кода происходит непосредственно на процессоре хоста.MMIO (Memory Mapped Input/Output) — пространство ввода‑вывода устройства, отображенное в общее адресное пространство системы. Обращение по таким адресам эмулируется QEMU как вызовы к моделям устройств (UART, таймер, GPIO и т. д.). При записи или чтении MMIO исполнение текущего TranslationBlock прерывается.
MMU (Memory Management Unit) — устройство управления памятью, транслирующее виртуальные адреса в физические и контролирующее доступ. В QEMU, даже если целевая система не имеет MMU, используется программная эмуляция. Отсюда термин softmmu для режима system TCG.
MPU (Memory Protection Unit) — устройство защиты памяти, управляющее доступом к физическим адресам (обычно в микроконтроллерах). В RISC-V аналогом является PMP.
PMP (Physical Memory Protection) — механизм защиты физической памяти, фиксированный в спецификации RISC-V. Настраивается через CSR (Control Status Registers) и действует на каждое ядро (per‑hart).
QEMU (Quick Emulator) — эмулятор/виртуализатор. По общей классификации является гипервизором второго типа (в режиме TCG) и, условно, первого типа (в режиме KVM). В русскоязычной литературе часто называют «виртуальной машиной».
RISC-V CSR (Control Status Register) — специальные регистры архитектуры RISC-V, управляющие состоянием процессора: прерывания, защита памяти, таймеры и т. д. Доступны через инструкции
csrr,csrw,csrrwи подобные. В QEMU их эмуляция реализуется через helper‑функции.System TCG (softmmu) — режим QEMU, в котором эмулируется целая система: процессор, память, периферия, прерывания. Даже если целевая система не имеет MMU, этот режим все равно называют softmmu.
TCG (Tiny Code Generator) — JIT‑компилятор (just‑in‑time), входящий в состав QEMU. Преобразует машинные инструкции гостевой архитектуры в нативные инструкции хоста, используя промежуточное представление TCG Ops.
TCG Ops (TCG operands) — операнды промежуточного представления TCG. Например,
add_i32,brcond_i32,mov_i32. Именно из них генерируется нативный код хоста.TCI (TCG Interpreter) — режим интерпретации, в котором TCG Ops выполняются напрямую, без трансляции в нативный код. Полезен для архитектур, где бэкенд TCG отсутствует, или для отладки. Включается ключом
--enable-tcg-interpreterна этапе конфигурации при сборке из исходников.TranslationBlock (TB) — единица трансляции в QEMU TCG. Непрерывная последовательность гостевых инструкций, что завершается ветвлением, переходом, выходом за границу страницы, специнструкцией или лимитом инструкций. Содержит сгенерированный нативный код и служебные поля для связывания блоков.
User TCG (user‑mode) — режим эмуляции отдельных Linux‑приложений. Системные вызовы транслируются ядру хоста, периферия не эмулируется. Работает только под Linux и BSD.
WFI (Wait For Interrupt) — инструкция (семейство), переводящая процессор в режим пониженного энергопотребления до наступления прерывания. В QEMU эмулируется helper‑функцией
helper_wfi.pc (Program Counter) — счетчик команд. Хранит адрес текущей исполняемой инструкции гостя. Используется в качестве ключа для поиска TranslationBlock в кеше.
Дополнительные материалы
К сожалению, со временем старые материалы становятся недоступны. Также часть материалов здесь — машинный перевод с китайского. Поэтому я перевел материалы в pdf и добавил в проект обертку.