本文节选自孙陈伟著《嵌入式Hypervisor架构、原里与应用》 PRTOS 通过分离内核架构以及半虚拟化技术实现。半虚拟化具有高性能和低复杂度的优势。如果客户操作系统或应用程序需要运行在虚拟化环境中,首先要做的就是修改源码,以调用半虚拟化服务。半虚拟化模型具备较强的性能优势,能够最大限度地提升系统实时性。 PRTOS 必须运行在处理器特权模式下,为分区提供虚拟化服务,并虚拟出 CPU、内存、中断和一些特定的外围设备,形成 PRTOS 基本架构,如图 2-2 所示。PRTOS 包含硬件依赖层、虚拟化服务层、内部服务层以及超级调用接口函数库。本章将分别介绍这 4 个组件。 硬件依赖层是嵌入式系统软件架构的底层,负责与底层硬件进行交互和通信,以控制和管理硬件资源。硬件依赖层包括设备驱动程序、硬件抽象层等组件。 在嵌入式系统中,硬件依赖层直接与硬件资源进行交互,对系统性能和功能具有很大的影响。通过硬件依赖层,软件可以直接控制和管理硬件资源,如 CPU、内存、外围设备等资源,从而实现软件系统的各项功能。同时,硬件依赖层还可以对硬件资源进行抽象和封装处理,以简化软件开发和维护的工作。虽然虚拟化技术在桌面系统领域取得了极大的进展,但在嵌入式领域的发展受到了诸多限制。虚拟化技术在嵌入式领域主要受到以下 4 个因素的限制。1)在硬件资源配置方面,嵌入式系统通常面临资源受限的局面,包括处理能力、内存和存储容量的限制。而虚拟化技术通常需要较多的计算和内存资源来管理虚拟机,这可能会导致嵌入式系统性能下降。2)在处理器选型方面,应用在嵌入式领域的处理器架构对硬件虚拟化的支持是非常保守的。比如应用在嵌入式领域的 MCU(Micro Control Unit,微控制单元)系列一般是 32 位的 ARMv7 架构,不支持硬件虚拟化。即使是多核处理器,为了节省成本,通常也不包含硬件虚拟化扩展组件。3)在软件设计方面,当应用程序需要满足实时需求时,引入 Hypervisor 后,应用程序、操作系统以及 Hypervisor 的设计均需要满足实时需求。4)在硬件设计方面,单核 / 多核处理器的底层架构设计会直接影响整个系统的实时性,其中流水线和高速缓存是影响系统实时性的两个主要因素。同时,考虑到 Hypervisor 是所有分区应用程序的公共软件层,为了使不同安全级别的分区应用共存于系统中,Hypervisor 的安全级别必须是整个安全关键系统中最高的。分区是 Hypervisor 创建的运行时环境,又称虚拟机或域(Domain),用于执行用户代码,并使得分区中的用户代码像在原生硬件平台上执行一样。在分区环境下,需要进行硬件抽象处理的资源包含以下 6 种。1)某些特殊的 CPU 寄存器资源,比如 Intel X86 处理器中的 CR3、GDTR(GlobalDescriptor Table Register,全局描述符表寄存器)、IDTR(Interrupt Descriptor Table Register,中断描述符表寄存器)等。5)X86 平台通过 I/O 端口地址管理 I/O 设备。分区通过 Hypervisor 提供的超级调用服务来使用虚拟化的资源。比如分区需要设置定时器时,不能直接访问硬件定时器资源,可以通过使用 Hypervisor 提供的定时器服务来实现定时功能。在分区环境下,以下 3 种硬件资源是不需要被虚拟化的。1)分配给该分区的内存地址空间:分区可以直接访问。2)非特权指令:可以直接在原生 pCPU 上运行。比如,一个分区代码执行一条加指令(ADD)时可以直接在 pCPU 上运行,不需要 Hypervisor 的参与。3)硬件高速缓存:高速缓存的使用对 Hypervisor 来说是透明的,这和在原生硬件环境下高速缓存的使用对操作系统透明是类似的。在多核处理器硬件平台,实现 Hypervisor 的虚拟化技术需要考虑一些特殊情况。比如,Hypervisor 在解决与缓存管理和信息安全相关的问题时,为了避免分区缓存的泄密隐患,分区被调度时通常采取刷新缓存的方式来避免敏感信息泄露。又比如,不同处理器核心对共享内存的访问会引入竞争问题,导致系统响应时间不确定。对此,虚拟化层只能缓解,不能彻底解决,需要系统在分区层通过更复杂的计算方式来估算 WCET,来确定临时的解决方案。处理器驱动主要负责初始化工作,包括设置处理器时钟、中断控制器、内存控制器等。本小节主要介绍与处理器时钟、中断控制器初始化相关的两类驱动,以实现 PRTOS 获取CPU 主频和设置 CPU 中断向量。本小节之所以不涉及内存控制器的初始化,是因为在 IntelX86 平台使用 GRUB(GRand Unified Bootloader,大统一启动加载器)来加载 PRTOS 系统映像时,已经对内存控制器做了初始化,PRTOS 无须对内存控制器再次初始化。在 Intel X86 硬件平台,可借助 64 位的 TSC(Time Stamp Counter,时间戳定时器)和Intel 8253(也称为 Intel 8254)PIT(可编程中断定时器)的计时通道 2,来计算当前 CPU的主频。TSC 可以对驱动 CPU 的时钟脉冲进行计数。Intel 8253 芯片的时钟输入频率是 1 193 180Hz。通过设定一定的初始计数值 LATCH(默认值为 65 535),就能控制该芯片的输出频率(默认为 1 193 180/65 535Hz)。例如,假定 LATCH=1 193 180/100,则能保证输出频率为 100Hz,即周期为 10ms。脉冲的精度是 1 /(CPU 主频)。比如,CPU 的主频是 500MHz,那么时钟脉冲的精度就是 2ns。可通过设置 Intel 8253 PIT 的计时通道 2 让定时器工作在模式 0 下。之所以选择通道 2,是因为通道 2 的输出电平可以通过 I/O 端口 0x61 的第 5 位读取。在模式 0 下,当计数器的值递减到 0 时,通道 2 的输出持续处于高电平状态,并且计数器只计数一遍,便于读取通道 2 的计数器值递减到 0 时的状态。这样我们就可以在通道 2 的计数值从 LATCH 递减到0 的时间段内,将 TSC 记录的时钟脉冲次数乘以(1 193 180/LATCH),计算得到的结果即CPU 当前工作频率。CPU 当前工作频率的具体计算过程如代码清单 3-1 所示。
//源码路径:core/kernel/x86/processor.c01 #define CLOCK_TICK_RATE 1193180 //时钟频率Hz04 #define CALIBRATE_MULT 10005 #define CALIBRATE_CYCLES CLOCK_TICK_RATE / CALIBRATE_MULT07 __VBOOT prtos_u32_t calculate_cpu_freq(void) {08 prtos_u64_t c_start, c_stop, delta;10 out_byte((in_byte(0x61) & ~0x02) | 0x01, 0x61);11 out_byte(0xb0, PIT_MODE); //二进制, 模式0, LSB/MSB, 通道212 out_byte(CALIBRATE_CYCLES & 0xff, PIT_CH2); //低8位写入13 out_byte(CALIBRATE_CYCLES >> 8, PIT_CH2); //高8位写入14 c_start = read_tsc_load_low();15 delta = read_tsc_load_low();44 嵌入式 Hypervisor:架构、原理与应用 17 delta = read_tsc_load_low() - delta;18 while ((in_byte(0x61) & 0x20) == 0)20 c_stop = read_tsc_load_low();22 return (c_stop - (c_start + delta)) * CALIBRATE_MULT;
提示:这里的源码路径是相对于 PRTOS 源码根目录的位置,即 https://github.com/prtos-project/prtos-hypervisor。后续源码路径均为相对于这个根目录的路径。在介绍中断向量初始化之前,我们先介绍中断种类和 Intel X86 处理器的中断向量表。中断的来源有两种:一种是由 CPU 外部产生的,另一种是 CPU 在执行程序过程中产生的。外部中断就是通常所讲的“中断”。对执行中的软件来说,外部中断是异步的,CPU(或者软件)对外部中断的响应完全是被动的,当然软件可以通过关中断指令关闭 CPU 的响应(这里不考虑系统重置等不可屏蔽中断)。而 CPU 在执行程序过程中产生的中断往往是由专设的指令有意产生的,这种主动的中断被称为陷阱。除此之外,还可能存在预期之外的中断,一般是同步的,被称为异常。例如程序中的除法指令(DIV),当除数为 0 时,就会发生一次同步异常。不管是外部产生的中断,还是内部产生的陷阱或异常,CPU 的响应过程基本一致,即在执行完当前指令之后或者在执行当前指令的中途,根据中断源提供的中断向量在内存中找到相应的服务程序入口并调用该服务程序。外部中断的向量是由软件或硬件设置好了的,陷阱向量是在自陷指令中发出的,其他各种异常的向量则是在 CPU 的硬件结构中预先设定的。这些不同的情况因中断向量号的不同而被分开。根据中断类型的不同 Hypervisor,挂载的中断处理程序也不同。PRTOS 的中断处理类型如图 3-1 所示。 在 X86 处理器中,中断向量表中的表项称为“门”,意思是当中断发生时必须先通过这些门,才能进入相应的服务程序。这里的门并不仅是为中断而设的,只要想切换 CPU 的运行状态(如从用户 Ring 3 进入系统 Ring 0),就需要通过一道门。而从用户模式进入系统态的途径也并不只限于中断(或者异常,或者陷阱),还可以通过子程序调用指令 CALL 来达到目的(PRTOS 的超级调用就是通过子程序调用指令 CALL 实现的)。而且当中断发生时,不但可以切换 CPU 的运行状态并转入中断服务程序,还可以安排一次分区切换(即分区上下文切换),立即切换到另一个分区。 根据用途和目的的不同,X86 CPU 的门共分为 4 种:任务门、中断门、陷阱门以及调用门。PRTOS 只初始化中断门和陷阱门。中断门、陷阱门均指向一个子程序,必须结合使用段选择子和段内偏移来确定这个子程序的位置。 中断门和陷阱门在使用上的区别不在于中断是由外部产生还是由 CPU 本身产生的,而在于通过中断门进入中断服务程序时,CPU 会自动将中断关闭(关中断),即将 CPU 中的标志寄存器(EFLAGS)的 IF 标志位清 0,以防嵌套中断的发生;而通过陷阱门进入服务程序时,则维持 IF 标志位不变。这就是中断门和陷阱门的唯一区别。不管是什么门,都通过段选择子指向一个存储段。段选择子的作用与普通的段寄存器一样。在保护模式下,段寄存器的内容并不直接指向一个段的起始地址,而是指向由 GDTR 或 LDTR 确定的某个段描述表中的一个表项。至于到底是由 GDTR 还是由 LDTR 所指向的段描述表,则取决于段选择子中的 TI 标志位。在 PRTOS 中只使用全局段描述表 GDT。对中断门和陷阱门来说,段描述表中的相应表项是一个代码段描述符表项。 CPU 通过中断门找到一个代码段描述符表项,并进而转入相应的中断处理程序。之后,CPU 要将当前 EFLAGS 寄存器的内容以及返回地址压入栈,返回地址由段寄存器CS 的内容和取指令指针 EIP 的内容共同组成。如果中断是由异常引起的,则还要将表示异常原因的出错代码也压入栈。进一步地,如果中断服务程序的运行级别不同(即目标代码段的 DPL 与中断发生时的 CPL 不同),还得更换栈。X86 的任务状态段(Task StateSegment,TSS)描述符结构中除包含所有常规的寄存器内容外,还有 3 对额外的栈指针(SS 和 ESP)。这 3 组栈指针分别对应 CPU 在目标代码段中的运行级别 Ring 0、Ring 1和 Ring 2。CPU 根据寄存器 TR 的内容找到当前的 TSS 结构,并根据目标代码段的 DPL从 TSS 结构中取出新的栈指针(SS 加 ESP),装入段寄存器(Segment Register,SS)和栈指针寄存器(Extended Stack Pointer,ESP),从而达到更换栈的目的。在这种情况下,CPU 不但要将 EFLAGS、返回地址以及出错代码压入栈,还要将原来的栈指针也压入栈(新栈)。(3)Intel X86 处理器中断向量表的定义及初始化在 Intel X86 硬件平台,PRTOS 中断向量表的定义如代码清单 3-2 所示。
//源码路径:core/kernel/x86/head.S
01 #include
02 #include
03 #include
04 #include
05 #include
06 ...
07 .data
08 PAGE_ALIGN
09 .word 0
10 ENTRY(idt_desc) //PRTOS中断描述符表
11 .word IDT_ENTRIES*8-1
12 .long _VIRT2PHYS(hyp_idt_table)
13 ...
14 ENTRY(hyp_idt_table) //PRTOS中断向量表的定义
15 .zero IDT_ENTRIES*8
16
中断向量表的初始化如代码清单 3-3 所示。
代码清单 3-3 中断向量表的初始化
//源码路径:core/kernel/x86/irqs.c
01 void setup_x86_idt(void) {
02 //setup_x86_idt()函数的具体实现,请参考PRTOS对应的源码文件
34 }
setup_x86_idt() 函数的主要功能如下。
1)完成外部中断向量服务程序的初始化(这里假设有 16 个外部中断)。
2)实现 X86 CPU 预留的 19 个陷阱门和异常门描述选项的初始化。