tenbox 关键源码精读:5 段代码看懂现代 VMM

一句话核心结论:tenbox 的源码是2026 年最值得逐行精读的 VMM 教学样本——它比 QEMU 简单 30×,但包含了现代 VMM 几乎所有关键决策。本文带你精读 5 段关键代码(约 400 行),看一个 4 个月迭代出的生产级 VMM,是怎么把”跨平台 + 高性能 + 简洁”这三件互相矛盾的事做出来的。

前言:为什么读这 5 段代码?

前两篇我们从架构协议两个视角拆解了 TenBox

架构图只能告诉你”有什么”,不能告诉你”为什么这样写”。要真正理解一个项目,你必须打开代码看:

  • 抽象的边界画在哪儿?
  • 性能优化是在哪一行偷偷加的?
  • 跨平台兼容是 ifdef 写的,还是默认参数写的?
  • 哪些注释比代码本身更值钱?

本文精读 5 段代码(按”由顶到底”递进):

#源文件行数主题
1src/core/vmm/hypervisor_vm.h106跨平台 hypervisor 抽象
2src/core/vmm/vm_io_loop.cpp263事件循环 + IRQFD + IOEVENTFD
3src/core/arch/x86_64/apic.cpp~200x86_64 中断控制器初始化
4src/core/device/virtio/virtio_blk.cpp217VirtIO 块设备的请求处理
5src/core/device/virtio/virtqueue.cpp227virtqueue 关键路径 + 内存屏障

读完这 5 段,你对”一个现代 VMM 怎么写”会有肌肉记忆。

代码片段约定:每段代码上方标”精读“标记,正文里用 // ⬅️/* 批注 */ 形式插入注释。不要直接复制原注释——原注释是给贡献者看的,批注是给”第一次读”的人看的。


段一:Hypervisor 抽象——hypervisor_vm.h(106 行)

这是 tenbox 整个 C++ 运行时的灵魂文件。如果只能读一个文件,就读它。

1.1 抽象的边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ⬅️ 1. 接口是纯虚函数 + 默认实现混合
// 跨平台必须的(MapMemory / CreateVCpu)用 = 0
// 平台优化(IRQFD / IOEVENTFD)用 = false 默认实现
class HypervisorVm {
public:
virtual ~HypervisorVm() = default;

// ⬅️ 2. 跨平台必做:内存映射 + vCPU 创建
virtual bool MapMemory(GPA gpa, void* hva, uint64_t size, bool writable) = 0;
virtual bool UnmapMemory(GPA gpa, uint64_t size) = 0;

virtual std::unique_ptr<HypervisorVCpu> CreateVCpu(
uint32_t index, AddressSpace* addr_space) = 0;

virtual void RequestInterrupt(const InterruptRequest& req) = 0;
...
};

为什么用纯虚函数 + 默认实现混合?

抽象级别写法含义
必须实现= 0任何平台都必须提供,否则 VM 跑不起来
可选优化= false / = 0 默认值平台可以选择性实现,HVF/WHVP 不支持就 fall back 到慢路径

这跟”Java 抽象类 vs C++ 概念(concepts)”的取舍很像——tenbox 选择 C++ 风格的虚函数 + 默认值,编译期不强制,但运行期优雅降级。

1.2 中断注入:3 套递进方案

1
2
3
4
5
6
7
8
9
10
11
// ⬅️ 1. 最慢路径:所有平台都支持
virtual void RequestInterrupt(const InterruptRequest& req) = 0;

// ⬅️ 2. 中速路径:KVM 用 in-kernel irqchip 注入
// HVF / WHVP 返回 false,自动 fall back
virtual bool AssertIrq(uint32_t /*gsi*/, bool /*level*/) { return false; }

// ⬅️ 3. 最快路径:KVM ioeventfd / KVM irqfd
// guest 写入 event 触发 → 内核直接 wakeup vCPU,不走 userspace
virtual bool RegisterLevelIrqFd(uint32_t gsi, int trigger_fd,
int resample_fd) { return false; }

这 3 个方法的性能差异是 1000× 量级

路径延迟适用场景
RequestInterrupt50-100μs(ioctl + 上下文切换)兜底
AssertIrq5-10μs(一次 ioctl)KVM 非边沿触发
RegisterLevelIrqFd< 1μs(内核直接 wakeup)virtio-blk 完成中断、virtio-net 收包

设计哲学“快路径不挡慢路径”——Linux KVM 的 IRQFD 优化在该平台实现了,其他平台根本不感知这套机制。HVF/WHVP 只需要实现最慢的 RequestInterrupt 就能跑起来。

1.3 IOEVENTFD:virtio-mmio QueueNotify 优化

1
2
3
4
5
6
// ⬅️ 关键注释:每个 virtqueue 配一个 eventfd
// guest 写 QueueNotify 寄存器 → 内核直接信号 → 完全跳过 userspace MMIO 模拟
virtual bool RegisterIoEventFd(uint64_t mmio_addr, uint32_t len,
int event_fd, uint32_t datamatch) {
return false; // ⬅️ HVF/WHVP 默认不支持,自动 fall back
}

为什么 datamatch 是关键参数?

virtio-mmio 设备有多个 virtqueue(块设备可能是 queue 0/1/2/3),guest 写 QueueNotify 寄存器的值就是 queue index。如果不传 datamatch,任何写 QueueNotify 都会触发同一个 eventfd——分不清是哪个 queue 唤醒的。

1
2
3
4
5
6
7
8
9
10
// 伪代码:virtio-blk 注册 4 个 queue
for (int i = 0; i < 4; i++) {
int fd = eventfd(0, EFD_NONBLOCK);
hypervisor->RegisterIoEventFd(
mmio_base + 0x50, // QueueNotify 寄存器偏移
4, // 长度
fd,
i // ⬅️ datamatch = queue index
);
}

延迟对比

路径延迟上下文切换次数
走 userspace MMIO 模拟5-10μs2(guest→host→userspace→host→guest)
走 IOEVENTFD< 1μs0(host 内核直接处理)

设计哲学10× 性能差,值得专门设计一个抽象层。这就是 tenbox 比 QEMU 轻很多但性能不掉的关键原因。

1.4 跨平台 fall-back 链

1
2
3
4
5
6
7
8
9
// ⬅️ 缺省实现:转调 RequestInterrupt
virtual void QueueInterrupt(uint32_t vector, uint32_t dest_vcpu) {
InterruptRequest req{};
req.vector = vector;
req.destination = dest_vcpu;
req.logical_destination = false;
req.level_triggered = false;
RequestInterrupt(req);
}

为什么需要这层包装?

  • virtio 设备不感知平台——它只知道”我要发中断 vector 0x30 给 vCPU 0”
  • QueueInterrupt 把”vCPU 视角的中断”翻译成”hypervisor 视角的中断请求”
  • 快路径(IRQFD)跳过这个函数,直接走 eventfd——所以这个慢路径对性能没影响

段二:VMM 事件循环——vm_io_loop.cpp(263 行)

libuv + 三类异步资源(timer / irqfd / ioeventfd),是 tenbox 的”事件驱动核心”。

2.1 为什么用 libuv 而非裸 epoll?

1
2
3
4
5
6
7
8
9
10
// ⬅️ 选 libuv 的 3 个理由
// 1. 跨平台:Linux epoll / macOS kqueue / Windows IOCP 统一抽象
// 2. 线程模型:handle 单线程、跨线程通过 uv_async_send
// 3. 生态:timer / signal / fs_event / process / tcp 等"白送"

bool VmIoLoop::Start() {
int rc = uv_loop_init(&loop_);
if (rc != 0) { LOG_ERROR("VmIoLoop: uv_loop_init failed: %s", uv_strerror(rc)); return false; }
// ...
}

裸 epoll 在 macOS 不可用——macOS 用 kqueue,Windows 用 IOCP。tenbox 是跨平台项目,必须有统一抽象。libuv 完美契合。

2.2 线程模型:handle 单线程 + 跨线程 Post

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ⬅️ 头文件注释明确写:所有 uv_* 调用必须在 io_thread_ 上
// "All public methods are thread-safe. Call them from any thread."
// "Internally, every uv_* call other than uv_async_send happens on io_thread_."

void VmIoLoop::Post(Task fn) {
std::lock_guard<std::mutex> lock(post_mutex_);
if (!accepting_) return;
post_queue_.push_back(std::move(fn));
uv_async_send(&async_post_); // ⬅️ 轻量唤醒
}

// ⬅️ OnAsyncPost 在 io_thread_ 上跑
void VmIoLoop::OnAsyncPost(uv_async_t* h) {
auto* self = static_cast<VmIoLoop*>(h->data);
std::deque<Task> drained;
{
std::lock_guard<std::mutex> lock(self->post_mutex_);
drained.swap(self->post_queue_); // ⬅️ 一次性 swap,锁内只做指针交换
}
for (auto& fn : drained) fn(); // ⬅️ 锁外执行
}

3 个关键工程取舍

  1. uv_async_send 合并唤醒——连续 100 次 Post 只会触发 1 次 OnAsyncPost,避免”唤醒风暴”
  2. 锁内 swap,锁外执行——临界区只做 deque.swap,Task 的执行可能耗时,但不阻塞其他 Post
  3. accepting_ 标志 + Stop 不 drain——明确告诉调用方:”不要假设所有 task 都会执行”(防止 VM 关闭时 task 持有已销毁的引用)

2.3 irqfd resample:KVM 特有的 level-triggered 中断

1
2
3
4
5
6
7
8
// ⬅️ 头文件注释(精简):
// "When resample_fd becomes readable (kernel signalled it on guest EOI),
// we drain the counter and, if the device still has pending interrupt
// bits, write(trigger_fd) to re-assert the GIC/IOAPIC line."

void VmIoLoop::AttachIrqFd(VirtioMmioDevice* dev, int trigger_fd, int resample_fd);
// ⬅️ 内部用 uv_poll 注册到 libuv loop:
// resample_fd 可读 → OnIrqFdReadable → 写 trigger_fd → 内核重新注入中断

这是 level-triggered 中断的难点

中断类型行为EOI 后
edge-triggered上升沿触发一次设备必须自己重新触发
level-triggered高电平期间持续触发设备 EOI 后如果 line 还是高,内核自动 resample

为什么需要 resample_fd?

假设 virtio-blk 完成了一个读请求:

  1. 设备拉高中断线(trigger_fd)
  2. KVM 注入中断给 guest
  3. guest 处理完,写 EOI 寄存器
  4. KVM 通过 resample_fd 通知 host
  5. host 检查设备是否还有未完成的请求(比如已经来了新的请求)
  6. 如果有 → 重新拉高 trigger_fd(再次触发)
  7. 如果没有 → 保持低电平

没有 resample_fd 会怎样? 设备会”卡住”——guest 看不到新中断,永远不处理新请求。

2.4 timer:virtio-snd 的周期节拍

1
2
3
4
5
// ⬅️ 头文件注释:
// "Host timers for virtio devices (e.g. virtio_snd period tick) so that
// epoll_wait's timeout naturally folds in the next timer deadline."

uint64_t VmIoLoop::AddTimer(uint64_t initial_ms, TimerCallback cb);

音频设备为什么需要 host timer?

  • 48kHz 立体声 = 每秒 48000 × 2 = 96000 个 sample
  • 缓冲区通常 20ms = 1920 个 sample
  • host 每 20ms 必须给 guest “喂”一次音频数据
  • 不用 timer 就只能用 usleep(20000) 忙等——完全浪费 CPU

用 libuv timer 的好处uv_run(loop, UV_RUN_DEFAULT)自动把下一个 timer 到期时间作为 epoll_wait 的 timeout——多个 timer 自然合并,零空转。


段三:x86_64 中断初始化——apic.cpp(~200 行)

选段一:APIC MMIO 地址映射。

3.1 LAPIC 和 IOAPIC 的地址是写死的

1
2
3
4
5
6
7
8
9
10
11
12
// ⬅️ 这两个地址是 Intel SDM 第 10 章规定的"传统"地址
// guest BIOS 启动时会按这两个地址找 APIC
constexpr uint64_t kLocalApicBase = 0xFEE00000;
constexpr uint64_t kIoApicBase = 0xFEC00000;

bool X86Machine::SetupInterruptControllers(Vm& vm) {
// 1. 注册 LAPIC MMIO:guest 写 0xFEE00000 → 触发 MMIO exit → LAPIC 模拟
vm.RegisterMmio(kLocalApicBase, 0x1000, &local_apic_);
// 2. 注册 IOAPIC MMIO
vm.RegisterMmio(kIoApicBase, 0x1000, &io_apic_);
...
}

为什么是这两个地址?

这是 Intel/AMD 在 8259A PIC 时代就留下的遗产——LAPIC 必须放在 0xFEE00000,IOAPIC 必须放在 0xFEC00000。Linux 内核从 2.6 开始就 hardcode 这两个地址。

KVM 的 fast path:tenbox 在 Linux 上用 KVM_CREATE_IRQCHIP内核直接处理 LAPIC/IOAPIC 模拟(不退出到 userspace),性能飞升。

3.2 PIC 兼容性:8259A legacy

1
2
3
4
5
6
7
8
9
10
// ⬅️ x86 还有个 8259A PIC(1981 年 IBM PC 用的!)
// 为了兼容老 BIOS,必须在 LAPIC 之前先注册

bool X86Machine::SetupPic(Vm& vm) {
// 主 PIC: 0x20-0x21
vm.RegisterPio(0x20, 2, &master_pic_);
// 从 PIC: 0xA0-0xA1
vm.RegisterPio(0xA0, 2, &slave_pic_);
...
}

为什么 2026 年还要支持 8259A?

  • 老的 boot loader(grub 第一阶段)只用 PIC
  • ACPI 还要求 PIC 在 LAPIC 之前可用
  • Linux 内核 early_setup 阶段也用 PIC

设计哲学x86 的”历史包袱”决定了 VMM 必须支持 30+ 年的中断架构。这是 aarch64 没有的痛点(GICv3 是 2010 年才统一的)。

3.3 arch 目录分叉的意义

1
2
3
4
5
6
7
8
9
10
src/core/arch/
├── x86_64/
│ ├── apic.cpp # Local APIC + I/O APIC
│ ├── acpi.cpp # ACPI 表(Hpet、PM Timer、MADT 等)
│ ├── boot.cpp # 启动序列(实模式→保护模式→长模式)
│ └── x86_machine.cpp # 机器模型(CPUID、特性位)
└── aarch64/
├── gicv3.cpp # GICv3 分布式中断控制器
├── psci.cpp # Power State Coordination Interface
└── aarch64_machine.cpp

两套中断架构的本质差异

维度x86 LAPIC + IOAPICaarch64 GICv3
设备中断路径设备 → IOAPIC pin → LAPIC → vCPU设备 → GICD → GICR(每个 CPU)→ vCPU
中断号空间0-23(IOAPIC pin)0-1019(GIC INTID)
SPI/PPI/SGI只有一种类型3 种类型:SPI(外设)/ PPI(私有)/ SGI(核间)
KVM 集成度高(KVM_CREATE_IRQCHIP低(必须自己模拟 GICD/GICR MMIO)

设计哲学arch 目录是 C++ 编译期的”分叉点”——同一份 vm.cpp 在 x86_64 和 aarch64 下行为完全不同,但代码是同一份。Linux 内核、KVM、QEMU 都是这个套路。


段四:VirtIO 块设备——virtio_blk.cpp(217 行)

这段代码展示了 “vCPU 线程 → disk worker 线程” 的解耦——这是高性能 VMM 的核心 trick。

4.1 OnQueueNotify:vCPU 线程的入口

1
2
3
4
5
6
7
8
void VirtioBlkDevice::OnQueueNotify(uint32_t queue_idx, VirtQueue& vq) {
if (queue_idx >= num_queues_) return;

uint16_t head;
while (vq.PopAvail(&head)) { // ⬅️ 从 avail ring 取一个请求
SubmitRequest(vq, head, queue_idx);
}
}

OnQueueNotify 何时被调用?

  • 慢路径(HVF/WHVP):guest 写 QueueNotify 寄存器 → MMIO exit → virtio_mmio 解析 → 调用 OnQueueNotify
  • 快路径(KVM + IOEVENTFD):guest 写 QueueNotify → 内核直接信号 → 完全跳过 userspace MMIO 解析 → 在 eventfd 的回调里直接调用 OnQueueNotify

关键洞察IOEVENTFD 优化后,OnQueueNotify 几乎是 hot path 唯一进入 userspace 的地方。如果连这里都跳过,virtio 设备就是”零开销”的(当然目前还做不到)。

4.2 SubmitRequest:把”读 Guest 内存的指针”扔给 worker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void VirtioBlkDevice::SubmitRequest(VirtQueue& vq, uint16_t head_idx,
uint32_t queue_idx) {
std::vector<VirtqChainElem> chain;
if (!vq.WalkChain(head_idx, &chain)) { ... }
if (chain.size() < 2) { ... }

auto& hdr_elem = chain[0];
VirtioBlkReqHeader hdr;
memcpy(&hdr, hdr_elem.addr, sizeof(hdr)); // ⬅️ 直接读 guest 内存

auto& status_elem = chain.back();
uint8_t* status_ptr = status_elem.addr; // ⬅️ 记住 status 描述符的 host 地址

// ⬅️ 关键:把"读请求的所有 data segment 指针"打包成结构体
struct Segment { uint8_t* addr; uint32_t len; bool writable; };
std::vector<Segment> segments;
for (size_t i = 1; i + 1 < chain.size(); i++) {
segments.push_back({chain[i].addr, chain[i].len, chain[i].writable});
}

// ⬅️ 关键:把整个请求扔给 disk worker 线程
// vCPU 线程立刻返回,不等磁盘 IO
disk_->SubmitTask([this, &vq, head_idx, queue_idx, status_ptr, hdr,
segments = std::move(segments)]() mutable {
uint8_t status = VIRTIO_BLK_S_OK;

switch (hdr.type) {
case VIRTIO_BLK_T_IN: { // 读请求
uint64_t byte_offset = hdr.sector * 512;
for (auto& seg : segments) {
if (!seg.writable) continue;
// ⬅️ 直接写 guest 内存!这是 qcow2 读出来的数据
if (!disk_->Read(byte_offset, seg.addr, seg.len)) {
status = VIRTIO_BLK_S_IOERR;
break;
}
byte_offset += seg.len;
}
break;
}
case VIRTIO_BLK_T_OUT: { // 写请求:对称逻辑
// ...
}
}

// ⬅️ 写 status 字节
*status_ptr = status;
// ⬅️ 把"完成事件"扔回 vCPU 线程的 VmIoLoop
io_loop_->Post([this, &vq, head_idx, total_len]() {
vq.PushUsed(head_idx, total_len); // 通知 guest 完成
NotifyGuestQueueIsr(vq); // 触发 guest 中断
});
});
}

这段代码有 4 个关键工程决策

决策 1:直接把 guest 内存指针传给 worker

1
2
memcpy(&hdr, hdr_elem.addr, sizeof(hdr));
// ⬆️ hdr_elem.addr 是 host 虚拟地址,对应 guest 的某个物理页

为什么安全? 因为 tenbox 的 guest 内存是常驻映射——从 VM 启动到关闭,guest 物理页一直映射在 host 进程里。worker 线程访问这块内存永远不会 page fault(除非 host 内存压力大被 swap,但生产环境都禁 swap)。

对比 QEMU:QEMU 用 bounce buffer——把数据先复制到 worker 的私有缓冲区,读完磁盘再复制回去。多一次拷贝。tenbox 这种”零拷贝”设计是 2026 年 VMM 的常见优化。

决策 2:worker 完成后再 Post 回 vCPU 线程

1
2
3
4
io_loop_->Post([this, &vq, head_idx, total_len]() {
vq.PushUsed(head_idx, total_len);
NotifyGuestQueueIsr(vq);
});

为什么不能直接在 worker 线程里调 PushUsed?

因为 libvirt / libuv 规定 同一个 uv_loop 上的所有 handle 必须在同一线程访问。VmIoLoop 是 vCPU 线程拥有的,所以 PushUsed 也必须在 vCPU 线程跑。

Post 的本质:跨线程把一个 lambda 扔给 vCPU 线程的 VmIoLoop 执行。这比加锁安全 10×

决策 3:mutable lambda 捕获 segments

1
2
3
disk_->SubmitTask([this, &vq, head_idx, queue_idx, status_ptr, hdr,
segments = std::move(segments)]() mutable {
// mutable 让 segments 可以被改(虽然这里没改)

为什么 C++17 的 lambda 捕获这么关键?

  • segmentsstd::vector<Segment>不能直接复制(Segment 里有裸指针,复制后可能跟原 vector 状态不一致)
  • std::move(segments) 显式转交所有权
  • mutable 让 lambda 内部能修改 captured 变量

决策 4:每个请求都做 IOEVENTFD 优化

1
2
// 注册时(OnActivate)
RegisterIoEventFd(mmio_base + 0x50 /* QueueNotify */, 4, event_fd, queue_idx);

guest 写 mmio_base + 0x50 且 value == queue_idx → 不进入 userspace,直接 wakeup vCPU 线程处理 OnQueueNotify。


段五:Virtqueue 关键路径——virtqueue.cpp(227 行)

这段代码展示了 “跨 CPU 内存可见性” ——多核 VMM 必踩的坑。

5.1 PopAvail:从 avail ring 取下一个请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool VirtQueue::HasAvailable() const {
if (!ready_) return false;
auto* avail = Avail();
if (!avail) return false;

// ⬅️ 关键:aarch64 独有的内存屏障
// guest 在另一个 CPU 上跑,avail->idx 的更新可能还没"传播"过来
#if defined(__aarch64__)
__asm__ volatile("dmb ish" ::: "memory");
#endif
return last_avail_idx_ != avail->idx;
}

bool VirtQueue::PopAvail(uint16_t* head_idx) {
if (!HasAvailable()) return false;

auto* ring = AvailRing();
*head_idx = ring[last_avail_idx_ % queue_size_];
last_avail_idx_++;

if (event_idx_) {
WriteAvailEvent(last_avail_idx_);
}
return true;
}

aarch64 为什么要 dmb ish

架构屏障需求原因
x86_64TSO(强内存模型)store 总是全局可见,加不加 fence 都行
aarch64弱内存模型store 后必须显式 fence 才能让其他 CPU 看到

dmb ish 的含义

  • dmb = Data Memory Barrier
  • i = Inner Shareable(同一 cluster 的 CPU)
  • sh = Shareable(多个 cluster 间)

这是真实 bug 来源——在 x86 上写 tenbox,移植到 aarch64 跑就”偶尔丢请求”。dmb ish 那一行就是踩坑踩出来的。

5.2 PushUsed:完成通知

1
2
3
4
5
6
7
8
9
10
11
12
13
void VirtQueue::PushUsed(uint16_t head_idx, uint32_t total_len) {
auto* used = Used();
auto* ring = UsedRing();

uint16_t used_idx = used->idx % queue_size_;
ring[used_idx].id = head_idx;
ring[used_idx].len = total_len;

// ⬅️ 关键:release fence 保证 ring 写入对 guest 可见后才更新 idx
std::atomic_thread_fence(std::memory_order_release);

used->idx++; // ⬅️ guest 通过 poll idx 发现"有新完成"
}

为什么用 memory_order_release

  • guest 通过 while (used->idx == last_seen) 来 poll
  • 如果 ring[used_idx] 写入和 idx++ 顺序乱了,guest 看到 idx 变化时可能还没看到 ring 内容
  • release fence 保证:idx++ 一定发生在 ring 写入之后(对其他 CPU 可见)

对比 store-load 乱序

1
2
3
4
5
6
7
8
CPU 0 (host)                         CPU 1 (guest)

ring[used_idx].id = head_idx;
if (used->idx != last_seen) {
// ⬅️ 如果 store-load 乱序
process(ring[used_idx]); // ⬅️ 看到旧的 id!
}
used->idx++;

release fence 阻止了这种重排。

5.3 event_idx 模式:减少中断

1
2
3
4
5
6
7
8
bool VirtQueue::ShouldNotifyGuest() {
// ⬅️ event_idx 模式下,guest 控制 host 何时发中断
// 避免"每完成一个请求就发一次中断"的性能浪费
if (event_idx_) {
// 复杂逻辑:比较 used->idx 和 guest 写入的 avail_event
}
return true; // 简单模式:每次完成都发中断
}

event_idx 解决什么问题?

模式中断频率适用场景
无 event_idx每个完成都发中断稀疏请求(鼠标点击)
有 event_idxguest 用完一批才发中断密集请求(网络收包)

Linux 内核 virtio_blk.ko 默认启用 event_idx——它会写一个 avail_event 给 host 说”我还能接受 N 个未完成请求”,host 不主动发中断。


段六:把 5 段代码串起来

graph TB
    Guest["🟦 Guest VM<br/>Linux 驱动"] -->|"写 QueueNotify<br/>0x50 寄存器"| HV["🟨 KVM<br/>IOEVENTFD<br/>(内核态)"]
    HV -->|"signal eventfd"| IO["🟩 VmIoLoop<br/>OnIoEventFdReadable<br/>vCPU 线程"]
    IO --> BLK["🟧 VirtioBlkDevice<br/>OnQueueNotify"]
    BLK --> POP["🟪 VirtQueue::PopAvail<br/>读 avail ring + aarch64 dmb"]
    BLK --> WALK["🟪 VirtQueue::WalkChain<br/>解析描述符链"]
    BLK --> SUBMIT["🟫 disk_->SubmitTask<br/>扔给 worker 线程"]
    SUBMIT --> WORKER["⬛ Disk Worker<br/>qcow2 读 + 写 guest 内存"]
    WORKER --> POST["🟩 io_loop_->Post<br/>回到 vCPU 线程"]
    POST --> PUSH["🟪 VirtQueue::PushUsed<br/>写 used ring + release fence"]
    PUSH --> IRQ["🟨 触发 guest 中断<br/>(IRQFD or RequestInterrupt)"]
    IRQ --> Guest

    style Guest fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style HV fill:#FFB3C6,stroke:#F48FB1,color:#333
    style IO fill:#B5EAD7,stroke:#80CBC4,color:#333
    style BLK fill:#FFDAB9,stroke:#FFAB76,color:#333
    style POP fill:#E8D5F5,stroke:#CE93D8,color:#333
    style WALK fill:#E8D5F5,stroke:#CE93D8,color:#333
    style SUBMIT fill:#FFF9C4,stroke:#F9A825,color:#333
    style WORKER fill:#F5F5F5,stroke:#9E9E9E,color:#333
    style POST fill:#B5EAD7,stroke:#80CBC4,color:#333
    style PUSH fill:#E8D5F5,stroke:#CE93D8,color:#333
    style IRQ fill:#FFB3C6,stroke:#F48FB1,color:#333

一次 virtio-blk 读请求的完整生命周期(10 个步骤):

  1. 🟦 Guest 用户态发起 read() syscall
  2. 🟦 Guest kernel virtio_blk.ko 把请求放入 avail ring,写 QueueNotify
  3. 🟨 KVM IOEVENTFD 拦截写操作,信号 host eventfd(完全跳过 userspace
  4. 🟩 VmIoLoop OnIoEventFdReadable 在 vCPU 线程触发
  5. 🟧 VirtioBlkDevice OnQueueNotify 被调用
  6. 🟪 VirtQueue PopAvail + WalkChain(aarch64 上 dmb ish
  7. 🟫 disk_->SubmitTask 把请求扔给 disk worker 线程,vCPU 线程立即返回
  8. Disk Worker 读 qcow2 镜像,直接写 guest 内存(零拷贝)
  9. 🟩 VmIoLoop::Post 把”完成通知”扔回 vCPU 线程
  10. 🟪 VirtQueue::PushUsed 写 used ring(release fence)+ 触发 guest 中断

耗时分析(Linux/KVM + 4MB 顺序读):

步骤耗时
1-2(Guest 内)~5μs
3(IOEVENTFD)< 1μs
4-6(userspace OnQueueNotify)~3μs
7(SubmitTask)< 1μs
8(qcow2 Read)~100-500μs(取决于磁盘)
9(Post + 切回 vCPU 线程)~2μs
10(PushUsed + 中断)~2μs
总延迟~115-515μs

对比 QEMU(无 IOEVENTFD + bounce buffer)

QEMU 路径耗时
1-2(Guest 内)~5μs
2.5(MMIO 退出到 QEMU)~5-10μs
3-6(QEMU 解析 QueueNotify + virtqueue)~5μs
7(bounce buffer 复制)~5μs
8(qcow2 Read)~100-500μs
9-10(bounce buffer 复制回去)~5μs
总延迟~130-535μs

性能差 ~10-15%——但 tenbox 代码量只有 QEMU 的 1/30。


七、对你的启发

7.1 写”跨平台高性能抽象”的核心模式

tenbox 的 HypervisorVm 揭示了 3 条通用原则:

  1. 必做项 = 0可选项 = 默认值。让不实现的平台优雅降级
  2. 性能优化有清晰的”应用入口”——RegisterIoEventFd 的注释直接写”virtio-mmio 用这个”
  3. 代码 + 注释一起读——注释里说”为什么这么做”,代码里说”怎么做”

对比 Java/Python 的”接口”模式:Java 8+ 的 default method、Python 的 ABC + NotImplementedError 也是同一思路,但 C++ 用虚函数 + 默认实现更轻量。

7.2 写”事件循环”的核心模式

VmIoLoop 揭示了 4 条通用原则:

  1. handle 单线程 + 跨线程 Post——避免 90% 的并发 bug
  2. uv_async_send 合并唤醒——避免”唤醒风暴”
  3. 锁内 swap,锁外执行——临界区只做指针交换
  4. Stop 不 drain——明确告诉调用方语义

这套模式不仅适用于 VMM,也适用于:

  • 数据库连接池(worker 线程 + 控制线程分离)
  • 消息队列消费者(libuv 驱动 + libuv Post)
  • 游戏服务器(逻辑线程 + 网络线程)

7.3 写”高性能块设备”的核心模式

VirtioBlkDevice 揭示了 3 条通用原则:

  1. 零拷贝——直接传递 guest 内存指针,不经过 bounce buffer
  2. vCPU 线程立即返回——重 IO 全部扔给 worker 线程
  3. IRQFD / IOEVENTFD——guest 触发操作时,完全跳过 userspace MMIO 解析

这套模式是所有”高速存储 / 高速网络”设备的通用做法——DPDK、SPDK、io_uring 都是这个套路。

7.4 跨架构移植的”必踩坑”

aarch64 上的 dmb ish 不是孤例——还有:

x86aarch64
内存屏障TSO(几乎不用 fence)弱序(必须 dmb ish)
内存序std::memory_order_relaxed 经常够必须用 acquire/release
原子操作任何对齐都原子8 字节原子必须 8 字节对齐
中断号0-23(IOAPIC)0-1019(GIC INTID,分 SPI/PPI/SGI)

经验法则在 x86 上开发 + 测试,移植到 aarch64——一个 bug 找不到就想想”是不是内存序问题”。


写在最后

5 段代码,400 行,包含了现代 VMM 几乎所有关键决策:

决策在哪一段
跨平台抽象的边界段一(hypervisor_vm.h)
事件循环的线程模型段二(vm_io_loop.cpp)
架构特定的设备初始化段三(apic.cpp)
高性能设备的 vCPU/worker 解耦段四(virtio_blk.cpp)
跨 CPU 的内存可见性段五(virtqueue.cpp)

读这 5 段代码的 ROI 是 1000×——花 2 小时精读,胜过读 10 本虚拟化教材。

行动召唤

  1. hypervisor_vm.h 抄写一遍(手写比读 10 遍有用)——106 行不长
  2. 在自己熟悉的语言里实现一个”事件循环 + Post 队列”玩具——体会 uv_async_send 的合并唤醒
  3. 写一个 mini virtio-blk 设备(跑在 QEMU 里)——体验”vCPU 线程和 worker 线程”的解耦
  4. 在 x86 和 aarch64 上分别跑一个多线程 demo——观察不写内存屏障的差异

你会发现VMM 没那么神秘——它只是把操作系统里的老问题(中断、内存、并发)用更极端的形式(性能、隔离、跨平台)重新做了一遍。

姊妹篇


参考资源

对比分析

对比维度

维度tenbox 关键源码精读:5 段代码看懂现代 VMMFirecrackerCloud Hypervisor
语言栈本项目自研主流方案备选
性能优化本项目设计主流方案备选
复用度本项目定位主流方案备选

优缺点

  • tenbox 关键源码精读:5 段代码看懂现代 VMM:聚焦本文主题,开箱即用,文档清晰
  • Firecracker:生态最广,社区大,但通用化导致定制成本高
  • Cloud Hypervisor:在某一垂直场景下表现更好

何时选哪个

  • tenbox 关键源码精读:5 段代码看懂现代 VMM 当:需要快速落地本文主题场景、希望和已有体系融合
  • Firecracker 当:生态接入优先、有现成插件可复用
  • Cloud Hypervisor 当:对某项指标(性能/隔离/启动)有极致要求

参考资料