跳转至

qemu

内存管理

无 kvm

guest 的代码在 tcg 翻译时,对于需要内存访问的指令,插入一个内置的地址查询函数,从软件维护的 tlb 中查询,如果不命中,则先根据软件维护的 guest 页表翻译为 GPA,GPA 根据 qemu 维护的 AddressSpace / MemoryRegion 转换成 HVA,或者触发设备回调,并把 HVA 和 GVA 的映射关系储存到软件 tlb 中。如果命中,直接获取到对应的 HVA,GPA 根据 MemoryRegion 转换为 HVA,此 HVA 属于 qemu 进程地址空间,直接执行由进程页表翻译到 HPA。

有 kvm

GVA(guest virtual address) 经过由 guest 自己维护的页表翻译成 GPA(guest physical address),GPA 是 guest 视角下的物理地址空间,它模拟了一台真实机器的物理内存布局,通常从 0x0 开始编号;但它只是虚拟机内部看到的物理地址,不等于 host 上真正的物理地址 HPA,在 KVM 模式下,CPU 硬件 MMU 会根据 guest 当前页表完成第一级地址翻译,也就是 GVA 到 GPA。之后 GPA 有 kvm 维护的 EPT/NPT 页表翻译到 HPA(host physical address) 执行。

  • 有 kvm 两级页表,两级由硬件完成
  • 无 kvm 两级页表,一级软件一级硬件

TCG

TCG(Tiny Code Generator),也就是 QEMU 的动态二进制翻译引擎。它的作用是:把“客户机 CPU 指令”翻译成“宿主机 CPU 指令”,从而让不同架构的程序或操作系统能够在当前机器上运行。

Guest 指令
前端翻译器
TCG IR
优化
后端代码生成器
Host 机器码
执行

Translation Block,简称 TB

TCG 不是一条指令一条指令地执行,而是以 Translation Block 为单位翻译。 一个 TB 通常是一段连续的 guest 指令,直到遇到:

分支跳转
异常边界
页边界特殊 
CPU 状态变化
但是在翻译 TCG 生成一个 TB 时,qemu 会给 TCG 中插入一些操作来模拟真机的操作

中断

  • 真机里对中断的检查是在指令周期的中断周期中,所以每条指令都有可能检查中断。
  • 而 qemu 无 kvm 中是在运行完一次 TB 执行后检查,上文中cpu_handle_interrupt(cpu, &last_tb)就是负责检查中断,之所以不是运行完一个 tb 就检查呢,是因为上文中tb_add_jump(last_tb, tb_exit, tb)会使用跳跃指令连接两个 tv,导致一次可能会执行多个 tb。
  • 由 kvm 提供系统调用给运行 vcpu 的真 cpu 直接发送中断信号,处理逻辑与真机一致

异常

  • 无 kvm
    • 翻译时可判断的直接插入 call helper 函数,无法知道的就添加运行时检查逻辑如果确实出现异常就 call helper,由 helper 跳转到 guest 的异常处理入口
  • kvm
    • vCPU 加载到真 CPU 时,会把 guest 的异常处理表基地址(IDT)加载到真 CPU 储存这个的寄存器上,所以运行时完全和真机一致,guest 内核代码和硬件来外层保护现场跳转到异常处理入口 总体来说是这个逻辑,但个别会有些特殊。
操作 有 kvm 无 kvm
内存访问(load/store) 页表查询等都由硬件来完成,与真机一致 每一处内存访问前都插入一个软件函数用来模拟 tlb,查询页表,cache,缺页等一一系列逻辑来获取物理地址,然后真正执行指令时用这个操作(当然硬件模拟并不是和真机一摸一样,相对简单)
Guest 访问 MMIO,port I/O QEMU 地址翻译发现不是 RAM,调用设备回调;必要时退出 TB CPU 二级翻译发现是 MMIO,KVM_RUN 返回 KVM_EXIT_MMIO,QEMU 处理设备
调试/断点 无 KVM 下,一个 TB 往往只翻译一条指令
helper 在翻译时就加入检测逻辑,每条指令都可单独触发调试事件。
KVM_EXIT_DEBUG 或架构调试退出
HLT/WFI/等待中断 helper 模拟 CPU halt,退出 TB 回到主循环,等待外部事件唤醒 guest CPU 自身进入 halted 状态。guest 行为与真机一致,无需 helper。
关机/reset 设置退出原因,返回上层主循环 KVM_EXIT_SHUTDOWN 等返回 QEMU
内部错误 QEMU 自己报错/abort/退出 KVM_EXIT_INTERNAL_ERROR / KVM_EXIT_FAIL_ENTRY

执行循环

首先是总的循环

do {
    if (cpu_can_run(cpu)) {             
        int r;
        bql_unlock();  // 释放 Big Qemu Lock(允许其他 CPU/线程并行访问资源)
        r = tcg_cpu_exec(cpu); // 执行 guest 指令块 (Translation Block),这是 vCPU 执行路径
        bql_lock(); // 再次获取锁,保证接下来的状态操作安全
        /* 根据 tcg_cpu_exec 返回值处理特殊事件/异常 */
        switch (r) {
            case EXCP_DEBUG:              // 如果是调试异常
                cpu_handle_guest_debug(cpu); // 调用调试处理函数
                break;
            case EXCP_ATOMIC:             // 如果是原子操作异常
                bql_unlock();                 // 解锁
                cpu_exec_step_atomic(cpu);    // 逐步执行原子操作指令
                bql_lock();                   // 再次加锁
                break;
            default:                       // 其他情况忽略
                break;
        }
    }

    qatomic_set_mb(&cpu->exit_request, 0); // 清除退出请求标志,保证下次循环可以正常执行
    qemu_wait_io_event(cpu); //事件循环

} while (!cpu->unplug || cpu_can_run(cpu)); 

  • 可以发现主循环里主要有两个循环,一是 vcpu 执行循环tcg_cpu_exec,二是事件循环qemu_wait_io_event
  • vCPU 执行循环
    • 执行 guest 指令块(Translation Block,TB),模拟虚拟 CPU 的指令流。
    • 处理异常和特殊指令,例如调试异常(EXCP_DEBUG)或原子指令(EXCP_ATOMIC)。
  • 事件循环
    • 处理外设 I/O 事件,比如后端连接的 tty 写入,会把回调函数挂载到事件队列里,执行循环时取出调用回调函数,讲数据写入到设备状态里并触发中断
    • 调用定时器和事件回调,处理到期任务。

vCPU 执行循环

  • tcg_cpu_exec 最终会调用 cpu_exec_loop
static int __attribute__((noinline))
cpu_exec_loop(CPUState *cpu, SyncClocks *sc)
{

    int ret;
    //检查是否有挂起的异常需要处理,如果有异常且需要退出
    while (!cpu_handle_exception(cpu, &ret)) {

        TranslationBlock *last_tb = NULL;

        int tb_exit = 0;


        //检查是否有挂起的中断需要处理,如果有则退出到外层循环,由外层来处理
        while (!cpu_handle_interrupt(cpu, &last_tb)) {
            //当前要执行的TCG块的指针
            TranslationBlock *tb;
            vaddr pc;
            uint64_t cs_base;
            uint32_t flags, cflags;
            cpu_get_tb_cpu_state(cpu_env(cpu), &pc, &cs_base, &flags);
            /*
             * When requested, use an exact setting for cflags for the next
             * execution.  This is used for icount, precise smc, and stop-
             * after-access watchpoints.  Since this request should never
             * have CF_INVALID set, -1 is a convenient invalid value that
             * does not require tcg headers for cpu_common_reset.
             */
             
            // 检查是否需要使用特殊的标志位(`cpu->cflags_next_tb`)。
            // 如果没有特殊要求,使用默认标志位(`curr_cflags(cpu)`)。
            // 如果使用了特殊标志位,执行后将其重置为默认状态(`-1`),以便后续 TB 恢复正常行为。
            cflags = cpu->cflags_next_tb;
            if (cflags == -1) {
                cflags = curr_cflags(cpu);
            } else {
                cpu->cflags_next_tb = -1;
            }
            //断点需要跳出循环,由外层循环处理
            if (check_for_breakpoints(cpu, pc, &cflags)) {
                break;
            }
            //找到tb,无缓存则翻译
            tb = tb_lookup(cpu, pc, cs_base, flags, cflags);
            if (tb == NULL) {
                CPUJumpCache *jc;
                uint32_t h;
                mmap_lock();
                tb = tb_gen_code(cpu, pc, cs_base, flags, cflags);
                mmap_unlock();
                //缓存翻译结果,以后使用
                //是一个键值对,但是存储空间有限,键重复的会把原先的覆盖,所以需要存储pc的原值负责比对
                //注意只存储了(pc,tb)键值对,但是不同进程空间pc会重叠,所以tb下回存储其他上下文信息。
                h = tb_jmp_cache_hash_func(pc);
                jc = cpu->tb_jmp_cache;
                jc->array[h].pc = pc;
                qatomic_set(&jc->array[h].tb, tb);
            }


//用户模式下可给tb结尾增加一个跳转指令来合并tb,系统模式下有换页等问题在tb处于不同分页时不进行这个操作,处于同一页内增加跳转指令
#ifndef CONFIG_USER_ONLY
            if (tb_page_addr1(tb) != -1) {
                last_tb = NULL;
            }
#endif
            if (last_tb) {
                tb_add_jump(last_tb, tb_exit, tb);
            }


            //执行tv
            cpu_loop_exec_tb(cpu, tb, pc, &last_tb, &tb_exit);
           
            align_clocks(sc, cpu);
        }
    }
    return ret;
}

事件循环

  • qemu_wait_io_event(cpu)最终会调用这个函数。具体是什么样的回调,下文设备管理中完全模拟 uart 后端接收就是一个例子
    void process_queued_cpu_work(CPUState *cpu)
    {
        struct qemu_work_item *wi;
        qemu_mutex_lock(&cpu->work_mutex);
        if (QSIMPLEQ_EMPTY(&cpu->work_list)) {
            qemu_mutex_unlock(&cpu->work_mutex);
            return;
        }
        while (!QSIMPLEQ_EMPTY(&cpu->work_list)) {
            wi = QSIMPLEQ_FIRST(&cpu->work_list);//取出队列里的回调函数
            QSIMPLEQ_REMOVE_HEAD(&cpu->work_list, node);
            qemu_mutex_unlock(&cpu->work_mutex);
            if (wi->exclusive) {
                bql_unlock();
                start_exclusive();
                wi->func(cpu, wi->data);//执行回调
                end_exclusive();
                bql_lock();
            } else {
                wi->func(cpu, wi->data);
            }
            qemu_mutex_lock(&cpu->work_mutex);
            if (wi->free) {
                g_free(wi);
            } else {
                qatomic_store_release(&wi->done, true);
            }
        }
        qemu_mutex_unlock(&cpu->work_mutex);
        qemu_cond_broadcast(&qemu_work_cond);
    }
    

设备管理

QOM

  • QOM(QEMU Object Model) 是 QEMU 提供的一套对象系统,用于:
    • 动态 注册类型(type)
    • 实例化对象(例如各种设备、芯片模型等)
    • 支持 单继承 + 多继承接口
    • 提供对对象属性(properties)的统一访问接口
  • 它类似于一个简化版的 C++/Java 类系统,但是是在 C 语言环境下实现的。
(qemu) info qom-tree
/ (TYPE_OBJECT)
├─ machine@pc                   # 主机/Board 对象
│   ├─ cpu0@                   # CPU 0
│   ├─ cpu1@                   # CPU 1
│   ├─ memory@ram              # RAM 对象
│   ├─ bus@pcie.0              # PCI Express 总线
│   │   ├─ root-port@16        # 根 PCIe 端口
│   │   ├─ virtio-net-pci@…    # virtio 网络设备
│   │   └─ virtio-blk-pci@…    # virtio 块设备
│   ├─ bus@isa.0               # ISA 总线
│   │   ├─ serial@…            # 16550 串口设备
│   │   ├─ rtc@…               # RTC 实时时钟
│   │   └─ pit@…               # Programmable Interval Timer
│   ├─ ide-cd@…                # IDE 光驱
│   ├─ vga@…                   # VGA 显卡
│   └─ usb-controller@…        # xHCI USB 控制器
└─ chardevs                    # 字符设备层
    ├─ chardev-vt100@…         # tty 相关
    └─ chardev-stdio@…         # Mon/Serial 映射

struct DeviceState {  
    Object parent_obj; /* QOM 父类对象:DeviceState 继承 Object */  

    /* 设备通用状态:id、realized、父 bus、子 bus、reset、hotplug、GPIO/IRQ、属性等 */  
    ...  
};  

struct PCIDevice {  
    DeviceState qdev; /* QOM 父类对象:PCIDevice 继承 DeviceState */  

    /* PCI 通用状态:config space、BAR、devfn、DMA、INTx/MSI/MSI-X、capability、option ROM 等 */  
    ...  
};
- 可以看到有这么一个结构体,就是“对象实例”,每创建一个 serial 设备,就有一个 SerialState。

另一层是 class,类似

struct PCIDeviceClass {
    //PCIDeviceClass 继承 DeviceClass
    DeviceClass parent_class;
    //PCI 自己新增的方法 虚方法槽
    void (*realize)(PCIDevice *dev, Error **errp);
    PCIUnregisterFunc *exit;
    PCIConfigReadFunc *config_read;
    PCIConfigWriteFunc *config_write;
    //自己新增的字段:这些字段是所有设备示例公用的,比如设备号什么的
    uint16_t vendor_id;
    uint16_t device_id;
    uint8_t revision;
    uint16_t class_id;
    uint16_t subsystem_vendor_id;       /* only for header type = 0 */
    uint16_t subsystem_id;              /* only for header type = 0 */
    const char *romfile;                /* rom bar */
};

  • 一个类型只有一个 class object,所有同类型实例共享它。QEMU 官方文档也明确说,每个类型都有一个 ObjectClass,通常保存虚方法函数指针表;而实例对象可以有很多个。

  • 所以说一个是实例化对象,一个是虚函数表以及一些公用成员,来实现 OOM。

  • 如果需要重载父类方法,就通过一个运行时初始化函数来实现 当然,如果 class 如果没有需要新加的字段,直接使用父类 class 再重载就可以,不需要定义自己的 class

    static void pci_device_class_init(ObjectClass *klass, void *data)
    {
        //kclass实际类型就是PCIDeviceClass,通过计算指针地址变异获取父类指针,然后覆盖方法
        DeviceClass *k = DEVICE_CLASS(klass);
        k->realize = pci_qdev_realize;
        k->unrealize = pci_qdev_unrealize;
        k->bus_type = TYPE_PCI_BUS;
        device_class_set_props(k, pci_props);
        object_class_property_set_description(
            klass, "x-max-bounce-buffer-size",
            "Maximum buffer size allocated for bounce buffers used for mapped "
            "access to indirect DMA memory");
    }
    

完全模拟

  • QEMU 独立实现设备逻辑,无需宿主机设备支持。且一般都是直接或者延时(运行几个 TB 后)调用回调,不用开线程
  • 优点:可在任何平台上运行,独立性高。
  • 缺点:性能相对低,因为所有设备逻辑都由 QEMU CPU 模拟。
  • 例子:
    • CPU/内存qemu32, qemu64, pentium, core2duo, arm1176, cortex-a8, cortex-a15, rv32, rv64
    • 存储ide-hd, ide-cd, pci-ide, lsi53c895a, megasas, qcow2, raw, vmdk
    • 网络e1000, e1000e, rtl8139, ne2k_pci, usb-net
    • 显示cirrus-vga, std, qxl, vga, virtio-gpu-fb
    • 输入ps2-keyboard, ps2-mouse, usb-mouse, usb-kbd
    • 总线/桥pci-root, i440fx, usb-ohci, usb-ehci, usb-xhci
    • 其他i82489dx, serial, parallel, ac97, sb16.

以 16550 UART 为例看一下设备如何执行

  • TCG 翻译模式下,内存访问会修改成插入一个 helper 函数根据内存区域表来决定是向内存单纯写,还是发现是一个设备的区域调用设备回调
  • kvm 模式下,硬件提供的第一级页表发现是设备区域就会退出 kvm 模式,会触发一个 KVM_EXIT_MMIOKVM_EXIT_IO 事件,qemu 根据事件消息来触发回调
  • uart 设备挂靠在 isa 总线下,但是模拟的 isa 总线只是一个注册分发机制,注册回调函数到指定的内存区域,通过 MemoryRegion 层层分发
    struct MemoryRegion {
        /* 父 MemoryRegion / 上一级表 */
        MemoryRegion *container;
        /* 子区域表,挂载在该区域下的子设备或子 MemoryRegion */
        QTAILQ_HEAD(, MemoryRegion) subregions;
        /* 链表节点,用于挂在父 MemoryRegion 的 subregions 链表 */
        QTAILQ_ENTRY(MemoryRegion) subregions_link;
        /* 如果该区域是别名,指向原始区域及偏移 */
        MemoryRegion *alias;
        hwaddr alias_offset;
        /* 冲突处理优先级 */
        int32_t priority;
        /* 回调函数表 */
        const MemoryRegionOps *ops;
        void *opaque;
        /* 名称,调试/对象树显示 */
        const char *name;
        ……
    };
    
    const MemoryRegionOps serial_io_ops = {
        .read = serial_ioport_read,
        .write = serial_ioport_write,//读写回调
        .valid = {
            .unaligned = 1,
        },
        .impl = {
            .min_access_size = 1,
            .max_access_size = 1,
        },
        .endianness = DEVICE_LITTLE_ENDIAN,
    };
    
    // 为设备创建一个 **MemoryRegion**,用于映射设备寄存器或 I/O 端口空间
    memory_region_init_io(&s->io, OBJECT(isa), &serial_io_ops, s, "serial", 8);
    //把 UART 的 MemoryRegion **挂载到 ISA 总线的 I/O 端口空间**
    isa_register_ioport(isadev, &s->io, isa->iobase);
    

也就是是说对 uart 对应内存区域读写会触发serial_ioport_readserial_ioport_write

  • serial_ioport_write写数据的逻辑

    case 0:
        // 如果 LCR 寄存器的 DLAB 位被设置,端口 0 不是 THR,而是 DLL(波特率低字节)
        if (s->lcr & UART_LCR_DLAB) {
            // 更新波特率低字节
            s->divider = deposit32(s->divider, 8 * addr, 8, val);
            // 根据新波特率参数更新内部传输时间、定时器等
            serial_update_parameters(s);
        } else {
            // DLAB=0,端口 0 为 THR(发送寄存器),写入要发送的数据
            s->thr = (uint8_t) val;
            // 如果启用了 FIFO
            if(s->fcr & UART_FCR_FE) {
                // FIFO 满了,先丢弃最旧的数据腾出空间(硬件特性:overrun overwrite)
                if (fifo8_is_full(&s->xmit_fifo)) fifo8_pop(&s->xmit_fifo);
                // 将新写入的数据放入发送 FIFO
                fifo8_push(&s->xmit_fifo, s->thr);
            }
            // 清除发送中断等待标志(写 THR 后,THR 中断状态已处理)
            s->thr_ipending = 0;
            // 清除 LSR 寄存器中的 THR Empty 和 Transmitter Empty 位
            // 因为写入了新数据,发送寄存器不再为空
            s->lsr &= ~UART_LSR_THRE;
            s->lsr &= ~UART_LSR_TEMT;
            // 更新中断线状态,如果需要触发中断,调用 qemu_set_irq()
            serial_update_irq(s);
            // 如果当前没有发送重试任务,则立即启动发送
            // 这会将 FIFO 中的数据通过 back-end 输出(例如控制台或虚拟串口)
            if (s->tsr_retry == 0) serial_xmit(s);
        }
    
    //先模拟向寄存器写和向fifo写两种可能,最终调用serial_xmit(s)来输出,而serial_xmit最红调用了
    int qemu_chr_fe_write(CharBackend *be, const uint8_t *buf, int len)
    {
        Chardev *s = be->chr;
        if (!s) {
            return 0;
        }
        return qemu_chr_write(s, buf, len, false);
    }
    
    CharBackend(字符设备后端) 是 QEMU 的抽象层,它提供统一接口,让 UART、串口、虚拟机监控台等输出“字符数据”,将其输出到主机的 TTY/串口,控制台窗口,文件,TCP/UDP socket,管道,使用-serial /dev/ttyS0 -serial file:out.txt -serial tcp:127.0.0.1:port,server指定后端实例是什么,这些与实例交互的后端会开一个线程

  • serial_ioport_read读取数据的逻辑

    case 0:
        if (s->lcr & UART_LCR_DLAB) {
            // 读取 DLL(波特率低字节)
            ret = extract16(s->divider, 8 * addr, 8);
        } else {
            if(s->fcr & UART_FCR_FE) {
                // FIFO enabled → 从 FIFO 弹出
                ret = fifo8_is_empty(&s->recv_fifo) ? 0 : fifo8_pop(&s->recv_fifo);
                ...
            } else {
                // FIFO disabled → 直接读寄存器 RBR
                ret = s->rbr;
                s->lsr &= ~(UART_LSR_DR | UART_LSR_BI);
            }
            serial_update_irq(s);
            ...
        }
        break;
    
    这里寄存器的值和 fifo 的值是有单独线程的后端来填写,下面这个函数是后端设置有数据传来触发加入事件循环的回调,过程就是后端会将这个函数指针加入事件循环的回调队列里,然后等到了事件循环执行时逐个执行
    //这个函数会绑定到后端的回调里,后端检测到比如tty有输入,就会触发这个函数,recv_fifo_put就会往fifo写
    static void serial_receive1(void *opaque, const uint8_t *buf, int size)
    {
        SerialState *s = opaque;
        if (s->wakeup) {
            qemu_system_wakeup_request(QEMU_WAKEUP_REASON_OTHER, NULL);
        }
        if(s->fcr & UART_FCR_FE) {
            int i;
            for (i = 0; i < size; i++) {
                recv_fifo_put(s, buf[i]);
            }
            s->lsr |= UART_LSR_DR;
            /* call the timeout receive callback in 4 char transmit time */
            //设置延时触发中断,避免这一次中断被忽略
            timer_mod(s->fifo_timeout_timer, qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + s->char_transmit_time * 4);
        } else {
            if (s->lsr & UART_LSR_DR)
                s->lsr |= UART_LSR_OE;
                s->rbr = buf[0];
                s->lsr |= UART_LSR_DR;
        }
        serial_update_irq(s);
    }
    //一串数据到来,立刻触发中断,cpu可能只处理了前面已经到的数据,就结束了处理,后面到的数据需要延时中断来告知,这就是为什么设置定时器的原因
    

不过还有一件很神奇的事情,无论是对 fifo 的写还是对 fifo 的读,都是一个无锁操作,而后端和主循环是两个线程,为什么不会发生冲突呢, 实际上是主循环维持了一个事件队列,后端触发回调只是把回调加入事件队列,具体执行还是有主循环执行,主循环要么执行 guest 操作要么执行回调,根本不会有冲突。

Virtio 设备模拟

  • Virtio 是 虚拟化设备标准,用于高性能虚拟化
  • 核心特点:
    • 客户机(Guest)通过 虚拟队列(Virtqueue) 传递请求
    • Virtqueue 是 环形/循环队列(Ring Buffer)
    • 设备驱动把 命令/请求描述符(Descriptor) 放入队列

以 virtio‑rng 设备为例

1 Guest 探测设备

  1. Guest 驱动扫描总线:
  2. 驱动读取 Virtio 特性寄存器(device features)
  3. 驱动初始化 virtio 状态机

2 Guest (驱动完成) 分配 virtqueue 和 buffer 内存

  • 分配 virtqueue 内存(guest kernel/驱动负责):
  • Descriptor Table:这里都是 qemu 测的结构体定义,驱动侧略有不同

        struct VirtQueue {
                // 描述符表、Available Ring 和 Used Ring 的内存布局  
                VRing vring;  
                // 设备完成的元素列表,用于驱动回收/处理  
                VirtQueueElement *used_elems;  
                // 下一个要从 Available Ring 弹出的描述符索引  
                uint16_t last_avail_idx;  
                // last_avail_idx 对应的循环计数位(wrap bit)  
                bool last_avail_wrap_counter;  
                // 设备收到的最新可用描述符索引(优化用)  
                uint16_t shadow_avail_idx;  
                // shadow_avail_idx 对应的 wrap bit(Packed Ring)  
                bool shadow_avail_wrap_counter;  
                // 设备写入的 Used Ring 最新索引  
                uint16_t used_idx;  
                // used_idx 对应的循环计数位  
                bool used_wrap_counter;  
                // 上一次通知驱动的 used_idx  
                uint16_t signalled_used;  
                // signalled_used 是否有效  
                bool signalled_used_valid;  
                // 队列是否开启通知机制  
                bool notification;  
                // 队列在 VirtIODevice 中的索引  
                uint16_t queue_index;  
                // 当前队列中正在使用的描述符数量  
                unsigned int inuse;  
                // MSI/MSI-X 中断向量号  
                uint16_t vector;  
                // 回调函数,用于设备输出数据给驱动  
                VirtIOHandleOutput handle_output;  
                // 指向所属 VirtIODevice  
                VirtIODevice *vdev;  
                // Guest 侧事件通知  
                EventNotifier guest_notifier;  
                // Host 侧事件通知  
                EventNotifier host_notifier;  
                // Host notifier 是否启用  
                bool host_notifier_enabled;  
                // 队列链表节点,用于链表管理  
                QLIST_ENTRY(VirtQueue) node;
        };
        struct VRing {
            //数组大小
            unsigned int num;
            // 下一个 available descriptor 的索引,用于 guest push 到 available ring
            int next_idx;
            // 下一个 used descriptor 的索引,用于 host/QEMU 写入 used ring
            int used_idx;
            // Descriptor Table 指针(数组),每个元素描述一个 buffer
            VRingDesc *desc;
            VRingAvail *avail;// Used Ring 指针,host/QEMU 完成请求后写入的环形队列
            VRingUsed *used;// Used Ring 指针,host/QEMU 完成请求后写入的环形队列
            SubChannelId schid;// 虚拟通道 ID(子通道),用于多队列或多通道设备区分
            long cookie;// 通用 cookie,可用于驱动/设备自定义状态或回调参数
            int id;// 队列 ID,用于区分同一设备下的多个 virtqueue
        };
        struct VRingDesc {
            //guest 的物理地址
            uint64_t addr;
            //buffer 的长度
            uint32_t len;
            //位图表示
            //bit 000 表示这个 descriptor 后面还有下一个 descriptor
            //bit 001 表示这个 buffer 是 **device 可写** 的。
            //bit 010 表示 `addr` 指向的不是普通数据 buffer,而是另一个 descriptor table。
            uint16_t flags;
            //下一个 descriptor 的索引号,在 table 中的索引
            uint16_t next;
        } __attribute__((packed));
        typedef struct VRingDesc VRingDesc;
    

    • Available Ring
      typedef struct VRingAvail {
          // guest 给 device 的标志位
          uint16_t flags;
          // guest 已经放入 available ring 的 descriptor 数量计数
          uint16_t idx;
          // descriptor table 的索引数组
          // 每一项都是一个 descriptor index,不是地址
          uint16_t ring[];
      } VRingAvail;
      
    • Used Ring
      typedef struct VRingAvail {
          // guest 给 device 的标志位
          uint16_t flags;
          // guest 已经放入 available ring 的 descriptor 数量计数
          uint16_t idx;
          // descriptor table 的索引数组
          // 每一项都是一个 descriptor index,不是地址
          uint16_t ring[];
      } VRingAvail;
      

    3 Guest 告知 Virtio 设备 virtqueue 物理地址

    • 通过向对应的 MMIO 写回触发 qemu 回调
      static void virtio_ioport_write(void *opaque, uint32_t addr, uint32_t val)
      {
          VirtIOPCIProxy *proxy = opaque;
          VirtIODevice *vdev = virtio_bus_get_device(&proxy->bus);
          uint16_t vector, vq_idx;
          hwaddr pa;
          switch (addr){
              …………
              case VIRTIO_PCI_QUEUE_PFN:
              pa = (hwaddr)val << VIRTIO_PCI_QUEUE_ADDR_SHIFT;
              if (pa == 0) {
                  virtio_pci_reset(DEVICE(proxy));//清空virtioqueue
              }
              else
                  virtio_queue_set_addr(vdev, vdev->queue_sel, pa);//设置virtioqueue,也就是VRing指针
              break;
              …………
           }
      }
      

    4 Guest 设置请求,写入 Descriptor Table(来源 linux virtio-rng 设备驱动)

    • 上层给 guest 驱动的数据常常是分散内存,不一定连续;用 virtio 的 descriptor 链可以直接描述这些分段,避免先拷贝拼成一整块。
    • guest 驱动维护 desc[] 的空闲索引池(通常是“空闲头索引 + next 索引链”),分配请求时从空闲链取索引。
    • 对请求涉及的每一段内存(包含给设备读的段、设备写回复的段,当然也可以都用一段内存,比如 virtio-rng 设备),guest 填充一个 VRingDesc:addr 是该段地址,len 是长度,flags 标识读写/是否还有下一段,next 指向下一段索引。
    • 这些 VRingDesc 通过 next 串成一个请求链后,把链头索引写入 VRingAvail.ring[],再递增 VRingAvail.idx,设备就能按这个头索引去取整条请求链处理。
    • guest 往一个 mmio 寄存器写来触发 qemu 的回调函数通知设备

    5 qemu virtio 设备处理请求写回复

    • 回调函数会把真正执行函数挂载到事件循环的队列里,跟之前的 uart 后端有输入时把写入设备状态的函数放入队列的机制一致,也就是说 rng 后端不是单独的进程,这里跟 uart 的不太一样,真正执行的函数会优先使用 getrandom() 系统调用,或者open("/dev/urandom")read()/dev/urandom 不存在再退到 /dev/random。然后写入之前与 guest 约定好的内存区域,然后触发中断

    硬件直通

cpu 虚拟化状态位

  • CPU 内部增加一个 虚拟化状态位(Intel VMX non-root / AMD Guest Mode),标记当前执行上下文是 Guest。
  • 物理寄存器共享,但通过 VMCS / VMCB 保存 Guest 和 Host 状态,实现寄存器切换。
  • 普通指令直接执行,敏感指令触发 VM exit → Hypervisor 处理。VMCS / VMCB 本质上是一组“不可运算的寄存器状态存储区”,与正常物理寄存器数量相同,用于保存 Guest/Host 寄存器状态,实现虚拟化下的安全切换

二级页表机制(EPT / NPT)0

  • Guest 页表:Guest OS 自己维护 GVA → GPA(虚拟地址 → Guest 物理地址)
  • EPT / NPT:硬件 + KVM 将 GPA → HPA(Host 物理地址)
  • CPU 硬件自动完成两级地址转换,必要时触发 EPT/NPT fault → KVM 修正。

硬件直通

  • IOMMU(Intel VT-d / AMD-Vi)
    • CPU / 芯片组内建 DMA remapping 单元。
    • 每个 PCIe 设备通过 IOMMU group 关联访问权限。
    • 设备的 DMA 请求经过 IOMMU 转换,最终映射到 Host 物理内存。
  • VFIO(Virtual Function I/O)其实是一个内核模块
    • 管理 PCI/Platform 设备的绑定和解绑。
    • 配置 IOMMU 表,确保 DMA 访问隔离。
    • 提供 用户态安全访问设备寄存器 的接口 原本有些设备只能驱动程序使用,现在提供一个在类似/dev/vfio/xxx这样的接口,可以通过open ioctl系统调用来操作,比如 GPU,高速网卡(PCIe 10G/25G 网卡),NVMe / 高速存储控制器,USB / 音频 / FPGA 等 PCIe 设备