汽车SoC芯片上一般采用虚拟化技术来隔离多个虚拟机,在同一硬件平台上运行多个系统,
例如一个虚拟机运行IVI Linux/Android系统,另一个虚拟运行Cockpit系统。通过虚拟化技术可以实现在同一SoC硬件平台上的资源隔离分区,如CPU核的分配,物理内存的分配和隔离,外设的分配等。汽车产品上一般是采用Type 1的hypervisor虚拟化方式,因为汽车产品上的系统比较固定,例如只有IVI和Cockpit两个系统,
多系统的资源规划也比较固定(如每个系统需要多少CPU核和物理内存资源较固定),所以可以在系统设计的时候就可以决定需要的资源。简单的hypervisor不需要动态分配CPU核资源和动态调度不同的虚拟机执行,可以由hypervisor静态决定。汽车产品上的虚拟化和服务器的虚拟化有很大的不同。
如今的汽车的SoC系统性能足够强大,可以在同一SoC上流畅运行多个软件系统。
但还有厂商考虑到虚拟化的引入带来的系统复杂度,性能开销和使用商用hypervisor方案带来的成本代价,他们希望不采用hypervisor方式以AMP的形式实现在同一SoC上运行多个软件系统。
本文探讨不采用hypervisor方式的挑战和可能的方案。
CPU核的分配和考虑
把CPU核静态分配给不同系统是可以实现的,因为CPU核本身比较独立,只要软件系统启动时只将规划给它的CPU核带进来。如Linux系统,在device tree CPU node里指定分配给此Linux kernel的CPU核们的MPIDR(硬件上的CPU ID),
如果有另一个Linux系统,则需要在这个Linux对应的device tree CPU node里指定分配不同于之前Linux的CPU MPIDR。
底层firmware, uboot使用规划给Linux的CPU核中一个作为primary CPU(Linux kernel本身不要求primary CPU为硬件上的CPU 0), 在primary CPU上引导系统启动到Linux kernel, 然后Linux kernel读取device tree CPU node, 将这些CPU作为secondary CPU带入到Linux kernel系统。
例如,Linux 0 系统的device tree CPU Node为:
cpu@0 {
device_type = "cpu";
compatible = "arm,armv8";
reg = < 0x00 0x00 >; //CPUID in MPIDR:0x000000
enable-method = "psci";
};
cpu@1 {
device_type = "cpu";
compatible = "arm,armv8";
reg = < 0x00 0x100 >; //CPUID in MPIDR:0x000100
enable-method = "psci";
};
Linux 1 系统的device tree CPU Node为:
cpu@0 {
device_type = "cpu";
compatible = "arm,armv8";
reg = < 0x00 0x10000 >; //CPUID in MPIDR:0x010000
enable-method = "psci";
};
cpu@1 {
device_type = "cpu";
compatible = "arm,armv8";
reg = < 0x00 0x10100 >; //CPUID in MPIDR:0x010100
enable-method = "psci";
};
电源管理的考虑
不同的系统可以有独立的EL3 firmware。这些底层的firmware可能通过PSCI提供power management的服务。但是系统设计可能只有一个底层的电源控制,比如只有一套SCP Cortex-M的电源管理系统和电源管理硬件。
这应该没有关系,因为PSCI对哪个CPU进行power on/Power up是由CPU的MPIDR来指定的,只要PSCI代码知道这个系统的CPU的层次结构(cluster,CPU ID),PSCI应该就可以正确开关相应的CPU核。
有人可能会担心如果SoC硬件系统只有一个Cluster,多个OS系统都是从Cluster里的CPU核里分配,那如果一个OS系统中的CPU核全部关了,它会不会试图把Cluster也关了,从而影响到另一个系统?目前的PSCI实现应该不会有问题,因为目前cluster开关是由SCP来决定的,当这个Cluster里最后一个CPU核关闭时,SCP才会去关Cluster。但SCP的代码可能还是需要一些考虑,比如其中一个OS希望做system power down时,需要考虑对另个系统的影响。
另外一个可能需要考虑的是DVFS:虽然Arm很多的处理器硬件上都支持在同一Cluster里的CPU核有独立的电源电压域,可以单独做DVFS,但很多SoC设计为了简单,小核们接到一个同一电压频率,大核们接到另一个电压频率上。这时就要考虑一个OS系统做DVFS对另一个系统的影响。
一致性(coherency)的考虑
多核处理器可以实现硬件cache一致性,很多人知道要数据一致性时,需要将内存属性在MMU页表里设置为Shareable,Cacheable。很多人认为如果把属性改为Non-Shareable, Cacheable, 那么硬件应该就不会对这段内存做一致性维护了。
首先,Linux kernel 一般的内存都设为了Shareable Cacheable, 如果修改kernel代码,把属性设为Non-Sharable, Kernel运行会出问题。再次,Arm的很多Cortex-A, Neoverse处理器设计有个特点:在同一Cluster的CPU核,即使软件上把某段内存设为Non-Shareable, Cacheable,实际上硬件上还是会维护cluster内部CPU核的数据一致性。
为了内存隔离,我们一般会规划不同的物理内存空间给不同的OS系统,比如第一个1GB给第一个OS系统,第二个1GB给第二个OS系统。因为它们访问的是不同物理地址空间,根据MESI/MOESI cache一致性协议,第一1GB的内存应该不会进入到第二OS系统的CPU核的cache中。
另个需要注意的地方是TLB Invalidate(TLBI)和 Instruction Cache maintance(CMO)操作的broadcast。OS kernel上执行的有些TLBI和 CMO会被硬件broadcast到其他CPU核。其他CPU核收到broadcast也会让硬件自动去做同样的TLBI和CMO操作。这个对于SMP系统很有益,因为SMP系统中,希望维护多核具有同样的TLB和Instruction cache内容。TLBI 操作可以对一个VMID, ASID, VA对应的TLB或是ALL TLB进行Invalidate操作。
但对于AMP系统,这会是一个坏处,因为不同的OS的VA到PA的映射是独立的,本不希望受到来自另一个OS的TLBI (带有特定VMID,ASID,VA或是ALL)的操作影响,因为它们的VA-PA映射没有关系的,因而没有必要维护一样的TLB view。但硬件上还是把这些TLBI和CMO操作相互broadcast,CPU核也还是会接收这些broadcast。
如果CPU核接收了来自另一个OS系统的broadcast,并也invalidate VA对应的TLB,或是全部的TLB,而这些invalidate对于这个OS系统是没有必要的,从而造成没必要的性能损失。
如何避免这些broadcast对AMP的系统影响呢?当CPU核接收到TLBI broadcast时,需要匹配broadcast过来的VMID和这个CPU核当前的VMID(CPU当前的VMID在VTTBR_EL2.VMID中设置)。只要给不同的OS系统设置不同的VMID,就可以避免不必要的TLB invalidate。VTTBR_EL2看起来是和内存虚拟化相关的寄存器,让人觉得必须使能虚拟化才能利用VMID。但实际上,根据Arm构架文档
Dependencies on the VMID for the Secure or Non-secure EL1&0, when EL2 is enabled, translation regime apply even when the value of HCR_EL2.VM is 0. The VTTBR_EL2.VMID field resets to a value that is architecturally UNKNOWN, and therefore VTTBR_EL2.VMID[7:0] must be set to a known value, that might be zero, as part of the PE initialization sequence, even if stage 2 translation is not in use.
设置VTTBR_EL2.VMID且让它影响TLB entry,并不需要使能虚拟化(即设置HCR_EL2.VM)。
因此,我们可以在各个OS系统启动时,设置不同VMID到VTTBR_EL2.VMID。例如在Linux kernel系统启动时,可以在这里设置vttbr_el2:
/* Stage-2 translation */
.macro __init_el2_stage2
msr vttbr_el2, xzr
.endm
内存隔离和考虑
如果不使用虚拟化实现不同OS系统的内存隔离,可以考虑规划不同物理内存空间给不同的OS系统,需要注意的是这种方式没有hypervisor提供的stage2内存权限管理来限制一个系统越界访问规划给另一个系统的物理内存。因此,需要在各个系统在管理MMU页表时,不要把没有规划给该系统的物理内存建立有效影响,避免错误或者Speculative访问到不属于此系统的内存。
Linux kernel的MMU页表映射只会为device tree memory node指定的内存建立映射,所以只要正确地将规划给Linux系统的内存指定到device tree memory node中,Linux 正常运行时不会去访问超出范围的物理内存。但driver可能会建立动态映射,如利用io_remap,或是Linux kernel本身出现错误,导致MMU映射被篡改,这可能使Linux系统访问超过规划给它的物理内存。如果系统是RTOS或是Bare Metal,不要为了简单把不属于它的物理内存在MMU页表中映射。
如果SoC设计采用CMN互联,且不同OS系统的CPU连接到不同的CMN的RN-F节点,此时可以考虑给不同的RN-F不一样的RN-F SAM配置,比如在SAM中不配置另一个系统的物理内存区域,系统如果错误发出属于另一系统的内存访问,会被RN-F SAM阻挡,路由到CMN的default node处理,从而避免越界访问。
内存资源的干扰和控制
另外一个需要考虑的问题:在同一SoC上运行多个系统,它们共享cache和内存带宽,如何避免cache占用和内存带宽的相互干扰的问题。如避免一个系统占用太多资源,而严重干扰到另一个系统。这个问题当然也存在于使用虚拟化hypervisor的系统。如果硬件上支持Memory System Resource Partitioning and Monitoring(MPAM),那么可以考虑利用它来监控资源使用率和控制各个系统的资源占用。
GIC的考虑
不利用虚拟化的情况下多系统共享一个GIC和SMMU有些困难,因为它们只有一个program interface。每个系统都认为独占整个GIC, SMMU,会导致资源访问的冲突。
GICv3有distributor, re-distributor, GIC CPU interface, ITS. GIC CPU interface和redistributor可以认为是per-CPU的。ITS和distributor是共享资源。
GIC的中断类型有PPI,SGI,SPI和LPI。PPI一般是per-CPU的,比如per-CPU的timer PPI中断。对于per-CPU的PMU中断,比较容易分配到各个系统中。
SGI一般用来实现inter-processor interrupt (IPI),如Linux kernel中
smp_call_function_many ->
arch_send_call_function_ipi_mask ->
smp_cross_call(mask, IPI_CALL_FUNC) ->
__ipi_send_mask ->
GICv3发送SGI有两种方法,由设置ICC_SGI0R_EL1.IRM或ICC_SGI1R_EL1.IRM决定,
IRM = 0
The interrupt is sent to <aff3>.<aff2>.<aff1>.<Target List>, where <target list> is encoded as 1 bit for each affinity 0 node under <aff1>. This means that the interrupt can be sent to a maximum of 16 PEs, which might include the originating PE.
除了Cortex-A53/A57/A72/A73,Cortex-A65, Neoverse-E1处理器之外,其他处理器TargetList都为0,因而IRM=0模式,SGI一般是发个某一个CPU。
IRM = 1
The interrupt is sent to all connected PEs, except the originating PE (self)。
IRM=1模式,将SGI发给处理发送者之外的,连接到此GIC上的其他CPU。
多个OS系统共享一个GIC,需要避免采用IRM=1模式,因为它会将一个系统上SGI对应的IPI发送到另个系统,从而导致另一个系统错误的IPI处理,进而导致系统问题。
在Linux系统上,SGI/IPI的发送采用IRM=0的方式,逐个发给Linux系统中的CPU,因此应该不会影响到另一个系统。
static void gic_ipi_send_mask(struct irq_data *d, const struct cpumask *mask)
{
for_each_cpu(cpu, mask) {
u64 cluster_id = MPIDR_TO_SGI_CLUSTER_ID(cpu_logical_map(cpu));
u16 tlist;
tlist = gic_compute_target_list(&cpu, mask, cluster_id);
gic_send_sgi(cluster_id, tlist, d->hwirq);
}
}
SPI作为共享外设中断,它们的使能,优先级配置,发送到哪些CPU处理的配置都是全局的。当多个OS系统共享一个GIC,它们可能都会对distributor/SPI进行初始化和配置,从而导致竞争风险。而系统规划时,可能会把一些SPI分配给一个系统,另外一些SPI分配给另一系统,期望系统应该不会配置和影响另一个系统的SPI。 但是,如Linux kernel在GIC driver初始化时,初始化此GIC支持的所有SPI(GIC driver初始化时,读取GICD_TYPE寄存器获得GIC上的SPI数量),而不仅仅是规划给Linux系统的SPI,这可能影响另一个系统。
/*
* Set all global interrupts to the boot CPU only. ARE must be enabled.
*/
affinity = gic_mpidr_to_affinity(cpu_logical_map(smp_processor_id()));
for (i = 32; i < GIC_LINE_NR; i++)
gic_write_irouter(affinity, base + GICD_IROUTER + i * 8);
for (i = 0; i < GIC_ESPI_NR; i++)
gic_write_irouter(affinity, base + GICD_IROUTERnE + i * 8);
}
static int gic_set_affinity(struct irq_data *d, const struct cpumask *mask_val,
bool force)
{
unsigned int cpu;
u32 offset, index;
void __iomem *reg;
int enabled;
u64 val;
if (force)
cpu = cpumask_first(mask_val);
else
cpu = cpumask_any_and(mask_val, cpu_online_mask);
if (cpu >= nr_cpu_ids)
return -EINVAL;
if (gic_irq_in_rdist(d))
return -EINVAL;
/* If interrupt was enabled, disable it first */
enabled = gic_peek_irq(d, GICD_ISENABLER);
if (enabled)
gic_mask_irq(d);
offset = convert_offset_index(d, GICD_IROUTER, &index);
reg = gic_dist_base(d) + offset + (index * 8);
val = gic_mpidr_to_affinity(cpu_logical_map(cpu));
gic_write_irouter(val, reg);
这可能要求对GIC相关的代码进行修改,比如修改GIC初始化代码,让它只初始化属于这个系统的SPI。在Device tree 中也只应包含这个系统的中断号。
GICv3发送SPI有两种方法,通过GICD_IROUTER.IRM设置,
IRM = 0
The interrupt is sent to <aff3>.<aff2>.<aff1>.< aff0>
IRM=0模式,SPI一般是发个某一个CPU。
IRM = 1
The interrupt is sent to all connected PEs, except the originating PE (self)。
IRM=1模式,将SPI发给处理发送者之外的此GIC上的其他CPU, GIC可以采用1 of N的模式来决定发到哪个CPU。
多个系统共享一个GIC,需要避免采用IRM=1模式,因为它可能会将一个系统上SPI发送到另个系统,从而导致另一个系统错误的SPI处理。目前Linux kernel SPI采用IRM=0的方式。
static void __init gic_dist_init(void)
{
affinity = gic_mpidr_to_affinity(cpu_logical_map(smp_processor_id()));
for (i = 32; i < GIC_LINE_NR; i++)
gic_write_irouter(affinity, base + GICD_IROUTER + i * 8);
}
对于ITS,一个GIC可以有多个ITS,每个ITS有自己的编程接口寄存器地址。ITS一般用来发送PCIe MSI中断。每个ITS有自己的Device table, Interrupt Translation Table, Collection Table和Command Queue.
下面的demo显示了,两个Linux都用同一ITS导致的冲突:
如果硬件系统中的GIC只有一个ITS,而这个ITS又需要被多个OS系统共用,那么比较难处理多个OS系统对ITS访问的竞争问题,因为每个系统都认为自己对ITS有独享的控制和配置。
在这样的系统中,最好规划多个硬件ITS,让每个OS系统独立控制分配给它的ITS硬件。但即使是这样还可能需要更多的考虑:
GICv3构架要求:系统的LPI配置必须是global也,也就是系统的LPI ID的分配和配置必须是global,这样的话,有可能两个OS无法同时管理这个global的LPI配置。GICv3中GICR_TYPER.CommonLPIAff用于指示那些redistributor的GICR_PROBASER必须执行同一LPI configuration table, 通常是同一chip上的同一GIC的所有redistributor。如果GIC支持multichip, 那么另一个chip上redistributor可以指向另一个LPI configuration table,但是这些LPI configuration table软件上需要保持一致。实际上,GIC-600,GIC-700上同一chip上的所有redistributor 的GICR_PROBASER是一个copy,也就是改任何一个GIC_PROBASER寄存器值,也会作用于其他redistributor的GIC_PROBASER。
因而,不通过hypervisor,让多个OS同时独立管理LPI和ITS是不太现实的。如果只有一个OS需要LPI/ITS,那应该是可以的。
SMMU的考虑
一般现代的SoC设计在外设下面都会接上SMMU。一个SMMU只有一个编程接口用于控制建立StreamID/SubStreamID对应需要的translation, 这需要配置 Stream Table,CD table等。一个SMMU也只有一个Command Queue, Event Queue等。
如果硬件系统只有一个SMMU,而多个OS系统都需要使用SMMU,那么是比较难处理的。因为像Linux Kernel这样的OS的SMMU driver会读取SMMU的ID寄存器,获得支持的StreamID的信息,从而初始化所有StreamID对应的Stream Table和Command Queue, Event Queu等,这些需要的内存是由Linux动态分配出来的。如果另一个系统也初始化和修改这些配置,就会导致混乱。因而最好在独立于各个OS系统的地方统一管理SMMU。
SMMU的共享比GIC更加困难,因为SMMU的这些配置目前都是存放在各个OS管理的内存中。即使通过修改SMMU driver代码,把它们放在一个预先规划好的内存段,但SMMU的中断由哪个OS来处理,Command的读写指针在各个OS之间如何协调同步,Event Queue的读写指针在各个OS之间如何协调同步,由哪个OS来处理都是需要解决的问题。
如果只有一个OS系统比如Linux需要SMMU,其他系统的外设虽然也接到SMMU上,但是可能不需要SMMU的地址转换。这也许会比较容易处理,但也需要把default的SMMU模式设置为bypass, 否则可能block住这些设备的访问。如果Linux kernel传入arm-smmu.disable_bypass的启动参数,那么Linux SMMU driver会default把外设对应的translation设置为abort, 这会影响到别的接了SMMU但是不用的系统。
硬件辅助隔离
有些SoC设计时就考虑了一些资源的管理和分区。例如NXP的imx8qm平台,它设计了一个eXtended Resource Domain Controller (xRDC)来强制硬件的分区管理。分区的配置由SYSTEM CONTROLLER UNIT(SCU)的Cortex-M子系统来设置。
每个transaction会被指定一个Domain ID,它用来指示transaction属于谁,还有一个core ID用来指示transaction来自哪个core. 这些信息由MDAC (Master Domain Assignment Controller)产生,会在slave端被检查以确定访问是否允许。
Slave端可以加上,
- PAC (Peripheral Access Controller):以64KB粒度控制设备的访问权限
- MSC (Memory Slot Controller):控制某一完整内存的访问
- MRC (Memory Region Controller):分成多个memory region控制访问
Prototype
这个原型在同一平台上不使用hypervisor运行两个Linux,每个Linux静态分配CPU和物理内存,GIC只初始化属于自己的SPI。此原型中只有一个Linuxs使用ITS,没有考虑SMMU的管理。
另一个原型在同一平台上不使用hypervisor运行一个FreeRTOS和一个Linux:
结语
不使用虚拟化在同一SoC上运行多个OS系统或许可行,但也面临很多挑战,特别是在GIC和SMMU共享的处理方面。