ARM-SMMU技术文档

摘要:介绍ARM-SMMU的开发过程。

背景知识

简单介绍SMMMU的原理与作用。

DMA是什么?为什么需要IOMMU?

运行在hvisor之上的虚拟机,需要和设备进行交互,但如果每次都等待CPU来主持这类工作,会使处理效率降低,那么就出现了DMA机制。DMA 是一种允许设备直接与内存交换数据而不需要 CPU 参与的机制。

那么我们可以大致得出虚拟机通过DMA和设备交互的过程,首先虚拟机发出DMA请求,告诉目标设备把数据写到哪个地方,然后设备根据地址写入内存。

但上述过程需要考虑一些问题:

  • hvisor对每个虚拟机都做了内存虚拟化,所以虚拟机发出的DMA请求的目标内存地址是GPA,在这里也叫IOVA,需要将这个地址转为真正的PA,才能写入到物理内存的正确位置。
  • 再者,如果不对IOVA的范围加以限制,那么代表着可以通过DMA机制访问任何一个内存地址,从而造成无法预见的严重后果。

所以我们需要一个既能帮我们做地址转换,又能保证操作地址的合法性的机构,就像MMU内存管理单元一样,这个机构就叫IOMMU,在Arm架构中它有另一个名字叫SMMU(后续都称SMMU)

现在你知道了,SMMU可以将虚拟地址转为物理地址,从而保证设备直接访问内存的合法性。

SMMU具体要做的工作

上面说到SMMU的功能类似MMU,MMU的作用对象是虚拟机或者应用程序,而SMMU的作用对象是每个设备,每个设备以sid作为标识,对应的表叫做stream table。该表以设备的sid作为索引,PCI设备的sid可以从BDF号获取:sid = (B << 5) | (D << 3) | F。

开发工作

目前我们在Qemu中实现了对SMMUv3的stage-2地址转换支持,创建简单的线性表,并且使用PCI设备进行了简单的验证。

IOMMU的工作还未并入主线,可以切换到IOMMU分支查看。

整体思路

我们将PCI HOST直通给zone0,即在提供给zone0的设备树中加上PCI节点,将对应的内存地址在zone0的第二阶段页表中做好映射,并确保中断注入正常。那么zone0就会自己去探测PCI设备并进行配置,而我们在hvisor中只需要做好SMMU的配置工作就好。

Qemu参数

machine 中添加 iommu=smmuv3 以开启SMMUv3支持,并在 global 中添加 arm-smmuv3.stage=2 开启第二阶段地址翻译。

注意在Qemu中尚不支持嵌套翻译,如果不指出 stage=2 ,则默认只支持第一阶段地址翻译,请使用Qemu-8.1以上版本,低版本中不支持开启第二阶段地址翻译。

添加PCI设备时请注意开启 iommu_platform=on

addr可以指定该设备的bdf号。

在Qemu模拟的PCI总线中,除了PCI HOST,还有一个默认的网卡设备,所以其余添加的设备的addr参数必须从2.0开始。

// scripts/qemu-aarch64.mk

QEMU_ARGS := -machine virt,secure=on,gic-version=3,virtualization=on,iommu=smmuv3
QEMU_ARGS += -global arm-smmuv3.stage=2

QEMU_ARGS += -device virtio-blk-pci,drive=Xa003e000,disable-legacy=on,disable-modern=off,iommu_platform=on,addr=2.0

在hvisor的页表中映射SMMU相关的内存

查阅Qemu的源码可知VIRT_SMMU对应的内存区域起始地址为0x09050000,大小为0x20000,我们需要访问这个区域,所以在hvisor的页表中必须进行映射。

// src/arch/aarch64/mm.rs

pub fn init_hv_page_table(fdt: &fdt::Fdt) -> HvResult {
    hv_pt.insert(MemoryRegion::new_with_offset_mapper(
        smmuv3_base(),
        smmuv3_base(),
        smmuv3_size(),
        MemFlags::READ | MemFlags::WRITE,
    ))?;
}

SMMUv3数据结构

该结构包含了将会访问的SMMUv3的内存区域的引用,是否支持二级表,sid的最大位数,以及stream table的基地址和分配的页帧。

其中的rp是所定义的 RegisterPage 的引用,RegisterPage 根据SMMUv3手册中第六章的偏移量进行设置,读者可自行查阅。

// src/arch/aarch64/iommu.rs

pub struct Smmuv3{
    rp:&'static RegisterPage,

    strtab_2lvl:bool,
    sid_max_bits:usize,

    frames:Vec<Frame>,

    // strtab
    strtab_base:usize,

    // about queues...
}

new()

在完成映射工作后,我们便可以引用对应的这段寄存器区域。

impl Smmuv3{
    fn new() -> Self{
        let rp = unsafe {
            &*(SMMU_BASE_ADDR as *const RegisterPage)
        };

        let mut r = Self{
            ...
        };

        r.check_env();

        r.init_structures();

        r.device_reset();

        r
    }
}

check_env()

检查当前环境支持哪个阶段的地址转换、支持什么类型的流表、支持多少位的sid等信息。

以其中检查环境支持哪种表格式为例,支持的表的类型在 IDR0 寄存器中,通过 self.rp.IDR0.get() as usize 获取 IDR0 的数值,通过 extract_bit 进行截取,获取 ST_LEVEL 字段的值,根据手册可知,0b00代表支持线性表,0b01代表支持线性表和二级表,0b1x为保留位,我们可以根据该信息选择创建什么类型的流表。

impl Smmuv3{
    fn check_env(&mut self){
        let idr0 = self.rp.IDR0.get() as usize;

        info!("Smmuv3 IDR0:{:b}",idr0);

        // supported types of stream tables.
        let stb_support = extract_bits(idr0, IDR0_ST_LEVEL_OFF, IDR0_ST_LEVEL_LEN);
        match stb_support{
            0 => info!("Smmuv3 Linear Stream Table Supported."),
            1 => {info!("Smmuv3 2-level Stream Table Supoorted.");
                self.strtab_2lvl = true;
            }
            _ => info!("Smmuv3 don't support any stream table."),
        }

	...
    }
}

init_linear_strtab()

我们需要支持第二阶段地址转换,且系统中的设备并不多,所以我们选择用线性表。

在申请线性表需要的空间时,我们应该根据当前的sid的最多位数得到表项的个数,乘上每个表项需要的空间 STRTAB_STE_SIZE ,进而知道需要申请多少个页帧。但SMMUv3对stream table的起始地址有着严格的要求,起始地址的低 (5+sid_max_bits) 位必须为0。

由于当前的hvisor中暂不支持这样申请空间,我们在确保安全的情况下,申请一段空间,并在这段空间里面选定一个符合条件的地址作为表基址,虽然这样会造成一些空间的浪费。

申请了空间后,我们可以将这个表的基地址填入 STRTAB_BASE 这个寄存器:

	let mut base = extract_bits(self.strtab_base, STRTAB_BASE_OFF, STRTAB_BASE_LEN);
	base = base << STRTAB_BASE_OFF;
	base |= STRTAB_BASE_RA;
	self.rp.STRTAB_BASE.set(base as _);

接着我们还要设置 STRTAB_BASE_CFG 寄存器,来表明我们使用的表的格式是线性表或者二级表,以及表项的个数(使用以LOG2的形式表示,即SID的最大位数):

        // format : linear table
        cfg |= STRTAB_BASE_CFG_FMT_LINEAR << STRTAB_BASE_CFG_FMT_OFF;

        // table size : log2(entries)
        // entry_num = 2^(sid_bits)
        // log2(size) = sid_bits
        cfg |= self.sid_max_bits << STRTAB_BASE_CFG_LOG2SIZE_OFF;

        // linear table -> ignore SPLIT field
        self.rp.STRTAB_BASE_CFG.set(cfg as _);

init_bypass_ste(sid:usize)

当前我们还未配置任何相关信息,需要先将所有表项置为默认状态。

对于每个sid,根据表基址找到表项的地址,即合法位为0,地址翻译设置为 BYPASS

	let base = self.strtab_base + sid * STRTAB_STE_SIZE;
	let tab = unsafe{&mut *(base as *mut [u64;STRTAB_STE_DWORDS])};

	let mut val:usize = 0;
	val |= STRTAB_STE_0_V;
	val |= STRTAB_STE_0_CFG_BYPASS << STRTAB_STE_0_CFG_OFF;

device_reset()

上面我们做了一些准备工作,但还需要一些额外的配置,比如使能SMMU,否则会导致SMMU处于disabled状态。

	let cr0 = CR0_SMMUEN;
	self.rp.CR0.set(cr0 as _);

write_ste(sid:usize,vmid:usize,root_pt:usize)

该方法用于配置具体设备的信息。

首先我们同样要根据sid找到对应的表项地址。

	let base = self.strtab_base + sid * STRTAB_STE_SIZE;
        let tab = unsafe{&mut *(base as *mut [u64;STRTAB_STE_DWORDS])};

第二步我们要指明,这个设备的相关信息,我们是要用来进行第二阶段地址翻译的,且这个表项是合法的了。

        let mut val0:usize = 0;
        val0 |= STRTAB_STE_0_V;
        val0 |= STRTAB_STE_0_CFG_S2_TRANS << STRTAB_STE_0_CFG_OFF;

第三步我们要说明当前这个设备分配给哪个虚拟机来用,并且开启第二阶段页表遍历,S2AA64 代表第二阶段翻译表是基于aarch64的,S2R 代表启用错误记录。

        let mut val2:usize = 0;
        val2 |= vmid << STRTAB_STE_2_S2VMID_OFF;
        val2 |= STRTAB_STE_2_S2PTW;
        val2 |= STRTAB_STE_2_S2AA64;
        val2 |= STRTAB_STE_2_S2R;

最后一步就是要指出第二阶段翻译的依据,就是在hvisor中的对应虚拟机的页表,只需要将页表基地址填入对应位置即可,即 S2TTB 这个字段。

这里我们也需要说明这个页表的配置信息,这样SMMU才知道这个页表的格式等信息,才能使用这个页表,即 VTCR 这个字段。

	let vtcr = 20 + (2<<6) + (1<<8) + (1<<10) + (3<<12) + (0<<14) + (4<<16);
        let v = extract_bits(vtcr as _, 0, STRTAB_STE_2_VTCR_LEN);
        val2 |= v << STRTAB_STE_2_VTCR_OFF;

        let vttbr = extract_bits(root_pt, STRTAB_STE_3_S2TTB_OFF, STRTAB_STE_3_S2TTB_LEN);

初始化以及设备分配

src/main.rs 中,进行了hvisor的页表初始化以后(映射了SMMU相关区域),可以进行SMMU的初始化。

fn primary_init_early(dtb: usize) {
    ...

    crate::arch::mm::init_hv_page_table(&host_fdt).unwrap();

    info!("Primary CPU init hv page table OK.");

    iommu_init();

    zone_create(0,ROOT_ENTRY,ROOT_ZONE_DTB_ADDR as _, DTB_IPA).unwrap();
    INIT_EARLY_OK.store(1, Ordering::Release);
}

接着需要分配设备,这一步我们在创建虚拟机的时候同步完成,目前我们只将设备分配给zone0使用。

// src/zone.rs

pub fn zone_create(
    zone_id: usize,
    guest_entry: usize,
    dtb_ptr: *const u8,
    dtb_ipa: usize,
) -> HvResult<Arc<RwLock<Zone>>> {
    ...

    if zone_id==0{
        // add_device(0, 0x8, zone.gpm.root_paddr());
        iommu_add_device(zone_id, BLK_PCI_ID, zone.gpm.root_paddr());
    }
  
    ...
}

简单验证

在qemu启动参数中开启 -trace smmuv3_* ,即可看到相关输出:

smmuv3_config_cache_hit Config cache HIT for sid=0x10 (hits=1469, misses=1, hit rate=99)
smmuv3_translate_success smmuv3-iommu-memory-region-16-2 sid=0x10 iova=0x8e043242 translated=0x8e043242 perm=0x3