Skip to content

两条主线:NVMe 队列+doorbell 与 VirtIO 特性谈判+virtqueue

共同的起点:PCI 枚举 + BAR self-assign + 多实例 MSI-X

三个驱动一开始都走同一条 PCI 路,咱们先把共性拎出来。

枚举靠 class 或 vendor/device 匹配pci.hpp 里给每种设备一个识别谓词:

cpp
constexpr bool is_nvme_device(uint8_t cls, uint8_t sub) {
    return cls == 0x01 && sub == 0x08;            // 大类 0x01=存储,子类 0x08=NVMe
}
constexpr bool is_virtio_block_device(uint16_t vendor, uint16_t device) {
    return vendor == 0x1AF4 && (device == 0x1001 || device == 0x1042);  // VirtIO 厂商 + blk 设备号
}

NVMe 用 class code(同类设备共用一个类号,不管哪家厂);VirtIO 用 vendor/device(VirtIO 厂商号 0x1AF4 是固定的,设备号区分 blk/net)。PCI::find_nvme / find_virtio_block / find_virtio_net 各自扫总线,命中就读 BAR、回填 PCIDevice

BAR self-assign 是这一章两个驱动共同的坑,值得先讲。PCI 设备的 BAR(Base Address Register)告诉驱动它的寄存器窗口想开在哪段物理地址。正常流程是 BIOS(咱们这边是 SeaBIOS)在启动时给每个 BAR 分配好地址,驱动直接读。可现实是:

  • NVMe 的 BAR0,SeaBIOS 给了全 0(没分配)。
  • VirtIO 的 modern BAR 是 64-bit prefetchable,SeaBIOS 给了正确的 type bits,却配了一坨垃圾高位(BAR4=0x4000000cBAR5=0xc0),read_bars 把它当 64-bit BAR 合并出 0xc040000000(~823 GB),映射过去落在被 poison 填掉的 RAM 区,读寄存器返回 0xcafebabecafebabe

两个驱动的解法一样——别信 BIOS,自己来:

cpp
// 1. 探 size:往 BAR 写全 1,读回的补码编码就是 size
write_bar(bar, 0xFFFFFFFF);
uint32_t raw = read_bar(bar);
uint64_t size = ~(raw & size_mask) + 1;       // NVMe BAR0 = 16384(16 KiB)

// 2. 写一个自己挑的固定 32-bit 槽(避开已知设备的 BAR)
write_bar(bar, 0xfeb40000);                    // NVMe;VirtIO 用 0xfeb60000
write_upper_bar(bar, 0);                       // 64-bit BAR 的 upper 写 0(< 4 GB)

// 3. 更新 dev.bar[],后面 map_bar 用这个

教训记一笔:PCI BAR 不能假定 BIOS 分配好了。读到 poison(全 0xcafebabe 之类)或全 0,第一反应该是"BAR 没分对",不是"设备坏了"。两个驱动都吃了这个亏,所以把 self-assign 抽成共用动作。

多实例 MSI-X。三个设备各要自己的 MSI-X 中断,而 MSI-X Table/PBA 得映射到不撞的虚拟地址槽。061 章给 msix_controller.cppinit 加了覆盖参数:

cpp
const uint64_t table_v = (table_virt != 0) ? table_virt : kMsixTableVirt;
const uint64_t pba_v   = (pba_virt != 0)   ? pba_virt   : kMsixPbaVirt;

xHCI 用默认槽(+0x40000/+0x41000),NVMe 传 +0x74000/+0x75000,virtio-blk/net 各再往后挪——同一套 MSI-X 基建,多设备共存。这部分是 061 打好的地基,这一章只是消费它。

主线一:NVMe——自己造队列 + 敲 doorbell

NVMe 把"提交命令、拿结果"这件事做成了队列对:你维护一个 Submission Queue(SQ)塞命令,设备维护一个 Completion Queue(CQ)写完成。你敲 SQ 的 doorbell 告诉设备"有新命令了",设备干完敲 CQ(写一条完成项 + 你轮询)。

enable():CC.EN ↔ CSTS.RDY 握手

NVMe 控制器启用是个状态机(nvme.cppenable()):先 disable(写 CC.EN=0 → 轮询等 CSTS.RDY=0),配好 Admin SQ/CQ 的物理地址和大小(写 AQA/ASQ/ACQ 寄存器),再 CC.EN=1 → 轮询等 CSTS.RDY=1。握手成了才算是"控制器就绪":

[NVMe] enabled (admin queue=64 RDY=1 doorbell stride=4)

Admin SQ/CQ 用 DmaPool 分配(64 项 × 64 B 的 SQ + 64 项 × 16 B 的 CQ,4 KiB 页对齐),NvmeControllerDmaBuffer 成员 → move-only。

doorbell + Identify:塞命令、敲门铃、轮询 phase

发一条 Identify Controller 命令的流程,把 NVMe 的交互模型整个走一遍:

  1. 构造命令(op 0x06 / CNS=0x01 / PRP1 指向一个 4 KiB DMA 缓冲)塞进 Admin SQ[tail],tail++(回绕)。
  2. 写 SQ tail doorbell——告诉设备"tail 到这了,有新命令"。每个队列占两个 stride 槽(SQ tail 在偶数槽、CQ head 紧跟在奇数槽),所以 SQ tail doorbell 的偏移 = 0x1000 + 2 × queue_id × stride,CQ head doorbell 紧跟在 0x1000 + (2 × queue_id + 1) × stride,stride = 4 << DSTRD(见 nvme.hpp 的注释:0x1000 + queue_id * (2 * stride))。
  3. 轮询 CQ[head] 的 phase 位。NVMe 规定 CQ 完成项的 status 最低位是 phase,初值 1;每回绕一圈(head 归零)cq_phase_ ^= 1CQE.status bit0 == cq_phase_ 就说明这是一条新完成。
  4. status >> 1 == 0 → 成功,4 KiB 缓冲里就是 controller data(VID/SN/MN)。

⭐ doorbell stride 解码坑

这一步有个值钱的坑。CAP 寄存器的 doorbell stride 字段 DSTRD 在 CAP 高 32 位的 bits[31:28]。早期代码写的是 (cap_lo >> 24) & 0xF——把 bits[27:24](其实是 TO 字段的高位)当成了 DSTRD,解出 15。

错成 15 会怎样?stride = 4 << 15 = 128 KiB。SQ tail doorbell 偏移 = 0x1000 + 2 × queue_id × 128KiB,第二个队列(qid=1)的门铃就敲到 BAR0 那 16 KiB 窗口外面去了——访问越界。正解是 (cap_lo >> 28) & 0xF = 0,stride = 4(标准值):

[NVMe] enabled (admin queue=64 RDY=1 doorbell stride=4)

教训:NVMe CAP 字段的位宽对 spec 版本敏感。DSTRD 在 NVMe 1.4 是 bits[31:28] 的高 4 位,而它前面 bits[27:23] 是 TO(超时)。把 TO 的高位误当 DSTRD,stride 就飞了。读 spec 寄存器,字段边界要对着版本核,别凭印象移位。

IO 队列 + PRP + IBlockDevice

Admin 队列只能发管理命令(Identify、Create IO Queue)。真读写要走 IO Submission/Completion Queue:create_io_queues() 建一对 IO 队列,数据读写命令进 IO SQ。数据地址用 PRP(Physical Region Page)——PRP1 指向一个物理页(这一章只做单页 R/W,PRP2 链留 follow-up)。

最后用 nvme_block_device.cpp 把"发一条 NVMe 读写命令、轮询 IO CQ 拿结果"包成 IBlockDevice——Ext2 见到的就是一个能 read/write 块的设备,跟 AHCI 那个一模一样。init.cpp 启动时 prefer NVMe(性能路径),挂不上就退 AHCI:

[INIT] rootfs on NVMe (perf path)    // 或 [INIT] rootfs on AHCI

主线二:VirtIO——谈判特性 + 共享环

VirtIO 是虚拟化环境(KVM/QEMU)里的标准设备协议。它的思路跟 NVMe 完全不同:不定义命令格式,而是谈判双方都支持哪些特性,然后共享一组叫 virtqueue 的描述符环

modern transport:cap 遍历 + 特性谈判

VirtIO PCI modern 设备把寄存器藏在 PCI capability list 里(virtio.cpp)。遍历 cap list 找四种 cfg:common(cfg_type=1)、notify(2)、isr(3)、device_cfg(4),各映射到 modern BAR 窗口的一个 4 KiB 区(common+0x0 / isr+0x1000 / device+0x2000 / notify+0x3000)。

然后走 status 机跟设备谈判:

ACK | DRIVER  →  读 device feature(两个 32-bit word)  →  写咱们要的 feature AND
            →  FEATURES_OK  →  setup_queue  →  DRIVER_OK

feature 是 64-bit(两个 32-bit word,先读 device 的,跟咱们支持的 AND 一把写回去)。谈判成功的标志:negotiated=0x100000000(VERSION_1,bit 32)、status=0xf(ACK|DRIVER|FEATURES_OK|DRIVER_OK 全亮):

[VirtIO] transport ready: common BAR4+0x0 notify BAR4+0x3000 (mult=4) isr=Y device=Y num_queues=1 features=0x10130006e54
[VirtIO] transport OK: negotiated=0x100000000 status=0xf

split virtqueue:desc / avail / used 三个环

VirtQueue 是 split virtqueue——三块 DMA 内存(virtqueue.cpp):

  • desc[]:描述符数组,每个描述符说"这段物理地址、这么长、可写/可读"。
  • avail[]:驱动写给设备的——"我放了这些 desc 给你,head 索引在 avail[idx]"。
  • used[]:设备写给驱动的——"我消费了这些,idx 追到这了"。

发一个请求:填 desc(virtio-blk 一个请求是 3-desc 链:请求头 + 数据 + 状态),把 head 写进 avail,更新 avail->idx,然后 kick(写 notify 寄存器:notify_base + notify_off × mult)通知设备。等 used->idx 追上目标,就是完成了。

virtqueue 的 used->idx 是 free-running(一直涨,不回绕取模),比 NVMe CQ 的 phase 位简单——你只比 idx 追没追上,不用管回绕翻转。

blk + net:同一个 transport,两种设备

传输层(VirtIODevice + VirtQueue)是共用的,上面挂两种设备:

  • virtio-blk(virtio_blk.cpp):每个请求 3-desc 链(请求头 / 数据缓冲 / 状态字节),read/write 方向在请求头里。同样包成 IBlockDevice 挂 Ext2。
  • virtio-net(virtio_net.cpp):RX/TX 各一个 virtqueue(设备上报 num_queues=3,含一个 ctrl 队列,但驱动这一章只建 RX+TX 两个)。包成 NetDevice 接网络栈,net_init.cpp 里它跟 e1000 并存,dev_for() 优先用它(ping 10.0.2.2 走 virtio RX/TX 验证)。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04