nos_
ENRU

Когда баг в эмуляторе: две странности QEMU TCG под AMD-V

В TCG из QEMU 10.2 чтение CR4 гостем не вызывает vmexit, а точка останова по данным во вложенном госте вешает CPU насовсем. Как мы это нашли и обошли.

8 мин чтения
ГипервизорQEMUОтладкаТестированиеx86-64
Read in English

Большая часть работы над гипервизором nos впервые запускается на процессоре, которого не существует. Разработка идёт на Mac, x86-64-версия nos работает там под TCG из QEMU, а TCG эмулирует SVM от AMD вместе с вложенной трансляцией адресов и не эмулирует Intel VMX вовсе — поэтому первым у гипервизора появился бэкенд AMD-V («Гипервизор»). С -cpu max эмуляция достаточно полна, чтобы внутри nos загрузился немодифицированный Linux. Но это значит и то, что каждый гейт проверяет сразу две реализации одного и того же мануала: нашу и QEMU.

Обжигались на этом и раньше: до QEMU 9.2 TCG вообще не пропускал гостя с выключенной страничной адресацией через вложенную таблицу страниц — поэтому встроенные гости стартуют в long mode, а гейты под TCG отказываются работать с QEMU старше 9.2 — ради единственного гостя, который запускает CPU в реальном режиме. В октябре 2026 года за два дня нашлись ещё два расхождения — оба, пока мы закрывали дыры, найденные ревью гипервизора. Ни одно не было багом nos, и оба выглядели в точности как он. Всё, о чём речь дальше, — это QEMU 10.2.0, TCG, -cpu max, а nos в роли гипервизора уровня L1.

CR4: чтение без выхода

Дыра

Под AMD-V nos не перехватывал ни одного управляющего регистра, так что CR4 целиком принадлежал гостю. Гость мог сбросить CR4.MCE — судя по исходникам Linux, ядро, собранное без X86_MCE, так и делает, — и тогда machine check во время его работы оборачивался не исключением, а shutdown: хост видел то, что выглядело как triple fault гостя, перезапускал его и так и не узнавал о #MC.

Исправление, 6576c12, состоит из двух половин. В hvarch — крейте, который следит, чтобы процессор хоста оставался процессором хоста, — каждый вход принудительно ставит MCE в CR4, с которым работает гость, и перехватывает каждую запись в CR4, что бы ни попросила политика уровнем выше (svm.rs):

const HOST_CR: u32 = vmcb::intercept::cr::WRITE_CR4;
// ...
            v.save.cr4 |= CR4_MCE;
            let c = &mut v.control;
            c.intercept_cr |= HOST_CR;

В hv, безопасной половине, перехватываются и чтения: гость читает тот CR4, который записал, а настоящий регистр сохраняет MCE (cr.rs). Проверяет это встроенный гость cr4 со своим обработчиком #GP: он сбрасывает MCE, пробует PKE (которого его CPUID никогда не предлагает), сброс PAE в long mode и бит 32, ставит PGE через R9 и после каждой записи читает CR4 обратно. На процессоре, который делает то, о чём просит VMCB, хост выполняет все одиннадцать обращений — шесть чтений и пять записей.

Что сделал TCG

Под hv-test.py на TCG ни одно чтение так и не привело к выходу. Гость читал сам регистр — с MCE, что бы он туда ни записал, — а хост выполнил только записи. При этом VMCB о чтениях просил: READ_CR4, бит 4 в слове перехватов CR, ставится для каждого гостя.

Ответ нашёлся в x86-декодере QEMU. В 10.2 функция decode_op в target/i386/tcg/decode-new.c.inc сверяет mov из любого управляющего регистра с битом перехвата чтения CR0 и прибавляет номер регистра к коду выхода, только если этот код ненулевой. А SVM нумерует такие выходы с нуля — так они и записаны в раскладке VMCB у nos (vmcb.rs):

    /// A `mov` from CR4, and one to it: a control register's reads at
    /// 0x000 and on, its writes at 0x010 and on.
    pub const CR4_READ: u64 = 0x004;
    pub const CR4_WRITE: u64 = 0x014;
    /// A `mov` from a debug register, at 0x020 and on by its number, and
    /// one to it, at 0x030 and on.
    pub const DR_READ: u64 = 0x020;
    pub const DR_WRITE: u64 = 0x030;

Чтение CR0 — это код выхода 0x000, единственный, который не проходит проверку «не ноль». Поэтому под TCG каждое чтение CR4 сверяется с битом чтения CR0, который nos не ставит, — и просто выполняется. Записи начинаются с 0x010 и проверяются как положено; отладочные регистры, с 0x020 и выше, — тоже. Настоящий процессор AMD и вложенный AMD-V в KVM останавливают чтение, как их и просили.

Как с этим живёт тест

Напрашивающийся обходной путь — перехватывать ещё и чтения CR0 — на TCG поймал бы чтения CR4, но на настоящем процессоре этот перехват ловит ещё и SMSW, которую могут выполнять пользовательские процессы гостя. За промах эмулятора расплачивалось бы настоящее железо, поэтому так делать не стали.

Вместо этого тест научился понимать, где он запущен. Лист CPUID 0x4000_0000 несёт сигнатуру гипервизора, под которым работает система, и TCG из QEMU подписывается как TCGTCGTCGTCG (guests.rs):

fn under_qemu_tcg() -> bool {
    hvarch::x86::cpu::hypervisor_signature() == Some(*b"TCGTCGTCGTCG")
}

На TCG проверка гостя ждёт значений самого регистра, с MCE, и пяти выполненных обращений; на любой другой машине с AMD-V — тех CR4, что гость записал, и одиннадцати. Чего она не ослабляет никогда, так это запроса: в любом случае VMCB обязан просить перехват чтений, а каждая запись обязана проходить через хост. Гейт допускает ровно два исхода:

    "cr4": [r"while the CPU's CR4 kept MCE; PKE, PAE cleared and bit 32 each a #GP, CR4 as it was",
            r"(?:it read back what it wrote, MCE cleared and then PGE set|its reads the CPU's own, QEMU's TCG)",
            r"\b(?:11|5) of CR4 performed, 3 refused"],

То, чего TCG проверить не может, проверяется на хосте: цель platform в hv-fuzz.py гоняет hv поверх собственной модели процессора и сверяет каждый сгенерированный mov с CR4 с тем, что должно было произойти; контрольная сборка, отвечавшая на чтения значением регистра, а не CR4 гостя, упала на четвёртом входе. А на самом TCG гость по-прежнему ловит тот баг, ради которого всё затевалось: контрольный прогон, в котором MCE не сохранялся нигде, упал там на первой же записи.

DR0–DR3: запись, которая никогда не заканчивается

Дыра

Через два дня дошла очередь до отладочных регистров. Четыре адреса точек останова принадлежат процессору, а не VMCB: vmrun переключает DR6 и DR7, но не DR0–DR3, — а mov гостя в эти регистры никто не перехватывал. Так что гость мог прочитать адреса точек останова, оставленные предыдущим гостем на этом CPU. 25eed37 перехватывает все чтения и записи этих регистров, хранит четвёрку каждого гостя и перед каждым vmrun загружает её в процессор, записывая только те регистры, что отличаются.

Зависание

Встроенный гость dr запускает две ВМ, одну за другой на одном и том же CPU. Каждая читает DR0–DR3 (вторая должна увидеть 0 там, где первая оставила свои значения), записывает свой шаблон и читает его обратно, а затем ставит точку останова, сработать которой может только её собственный DR0. Первая версия использовала watchpoint по данным: DR0 на двойное слово, DR7 = 0xD0401 — L0, запись 4 байт, — и затем запись по этому адресу.

Под TCG hv run так и не вернулся.

Зависание без отчёта — худший отказ, какой знает этот проект, и это выглядело как заклинивший гипервизор во всех отношениях. Что делает процессор, показал монитор QEMU (-monitor unix:...): info registers, снятый несколько раз, каждый раз показывал RIP на той самой записи, гостевые DR0 и DR7 и DR6 без B0. Виртуальный CPU не принимал прерываний — даже от таймера хоста, который обычно и заканчивает очередь гостя, — так что хост так и не получил свой процессор обратно, и ни один лимит времени, который nos ставит гостю, сработать не мог.

Чей это баг

В переключение между гостем и хостом только что добавились новые перехваты и новый путь загрузки регистров, так что первым подозреваемым был nos. Решающим стал эксперимент с контрольной сборкой вообще без перехвата DR и без загрузки DR: гость писал DR0 напрямую, новый код nos был вне игры. Она зависла точно так же. Собственное переключение DR7 в QEMU на VMRUN и #VMEXIT при чтении кода выглядит правильным: оба пути идут через cpu_x86_update_dr7 в svm_helper.c. Итак, как наблюдалось: TCG из QEMU 10.2 не может пройти дальше записи, за которой следит точка останова по данным внутри вложенного гостя, — он повторяет её бесконечно и не принимает прерываний, чьи бы DR0–DR3 ни стояли в процессоре. За настоящими процессорами AMD и вложенным SVM в KVM такого не замечено.

Точка останова на исполнение вместо watchpoint

Проверяемому свойству watchpoint по данным не нужен. Теперь каждая ВМ направляет DR0 на свой собственный ret — у ВМ A он на одном байте, у ВМ B на следующем, так что точка, оставшаяся на чужом адресе, не сработает, — включает её на исполнение и вызывает:

    0x48, 0x8B, 0x3C, 0x25, 0x08, 0x71, 0x00, 0x00,         // 95: mov rdi, [0x7108] ; its breakpoint: one of the two rets
    0x0F, 0x23, 0xC7,                                       // 9d: mov dr0, rdi
    0xB8, 0x01, 0x04, 0x00, 0x00,                           // a0: mov eax, 0x401 ; DR7: L0, on execution
    0x0F, 0x23, 0xF8,                                       // a5: mov dr7, rax
    0xFF, 0xD7,                                             // a8: call rdi ; a #DB at the ret, before it runs

Точка останова на инструкцию — это fault: #DB приходит до того, как ret выполнится, и возврат на него вызвал бы #DB снова. Поэтому обработчик гостя запоминает DR6 и адрес возврата и выключает DR7 перед iretq. Точка каждой ВМ обязана сработать ровно один раз, с DR6.B0, на её собственном ret — а это возможно, только если перед входом в процессор загружен её собственный DR0. Контрольные прогоны показали, что тест кусается: со старым переключением вторая ВМ прочитала адрес точки останова и шаблон первой, а с перехватом, но без загрузки точка не сработала вовсе. На хосте каждый из семи мутантов dr.rs провалил hv-fuzz.py не позже чем за 145 входов.

Что мы из этого вынесли

  • Эмулятор — это ещё одна реализация мануала. Гейт, который гоняется только на нём, может как выдумать баг гипервизора, так и спрятать настоящий. Поэтому гость cr4 сообщает, какой мир увидел, — «its reads the CPU's own, QEMU's TCG not stopping a read of CR4 though the VMCB asks», — а не проходит молча в любом случае.
  • Ослабляйте то, что наблюдаете, но никогда — то, о чём просите, а остальное проверяйте в другом месте: в модели процессора у фаззера, под вложенным KVM, на железе. Под VT-x — на EX44 с 2026-10-20 (86deabd) — у cr4 и dr свои ожидания.
  • На зависание — контрольная сборка. Убрать из пути собственный код — самый быстрый способ отличить свой баг от бага эмулятора, а info registers, снятый несколько раз, показывает, где застрял процессор, который не возвращается.

Обе странности записаны там, где следующий о них и споткнётся: в документации — в разделах built-in guests и What entering a guest cannot be told to do, в разделе о hv-test.py документации по тестам и в комментариях к двум гостям, которые их обходят.