首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >简单讲解嵌入式系统中的软硬件协同设计

简单讲解嵌入式系统中的软硬件协同设计

原创
作者头像
武状元
发布于 2026-10-16 10:51:29
发布于 2026-10-16 10:51:29
230
举报

一、引言

在嵌入式系统、操作系统和虚拟化系统中,硬件与软件并不是互相独立的两个部分,而是互相协调,互相配合的“同伴”。一个资深的软件工程师,需要深知硬件如何与软件交互,这样才能最大化的利用硬件资源。如果只会让AI生成代码,那么不久的将来确实会被AI替代。

在实际开发一块板子时,硬件与软件负责不同的部分。

硬件负责提供:

  • 处理器执行能力;
  • 内存和地址转换;
  • 总线与 DMA;
  • 中断控制;
  • 权限检查;
  • 虚拟化扩展;
  • 错误检测与状态记录。

软件则负责:

  • 发现硬件能力;
  • 配置寄存器;
  • 建立页表和 DMA 映射;
  • 分配系统资源;
  • 管理设备状态;
  • 处理异常和错误;
  • 为历史平台提供兼容方案。

实际情况下,软件使用硬件的都需要依靠硬件提供的寄存器,而是用寄存器的方法非常简单:

(unsigned long ) 0x20100000

这里实际是直接调用硬件提供的寄存器地址,这样可以修改寄存器,从而操控硬件,这里先把数字转换为指针,这样就是使用地址,然后再修改地址的内容。

实际操作系统中常常需要软件使用寄存器完成相应的功能,例如:

  • x86 处理器不会自动进入操作系统期望的 64 位分页环境,启动软件需要配置控制寄存器、页表和 GDT;
  • A20 地址线问题源于 8086 时代的地址回绕,现代启动代码需要兼容不同平台的 A20 控制方式;
  • IOMMU 可以在硬件中完成 DMA 地址转换和权限检查,但 DMA 页表和设备隔离域仍然需要软件建立;
  • ARMv8-A 提供 EL2、Stage-2 地址转换、虚拟中断和虚拟定时器,Hypervisor 仍然需要负责资源管理和异常处理。

这些案例背后有一个共同原则:

硬件实现机制,软件制定策略;硬件负责高频执行,软件负责配置、管理和异常处理。

二、什么是软硬件协同设计

2.1 硬件与软件不是简单的上下游关系

在很多初学者的理解中,硬件先设计完成,软件再按照手册编写驱动。

这种理解只适合非常简单的场景。

在实际芯片项目中,硬件和软件通常需要共同定义以下内容:

代码语言:txt
复制
硬件寄存器布局
DMA 描述符格式
中断状态和清除方式
复位和初始化顺序
地址转换规则
权限检查方式
错误上报机制
版本兼容策略

比如一个网络控制器,不仅要定义“发送使能位在哪一位”,还要定义:

  • 发送队列如何组织;
  • 描述符由谁分配;
  • 哪个字段表示描述符有效;
  • 设备读取描述符前是否需要软件执行内存屏障;
  • 设备完成发送后如何通知 CPU;
  • 中断状态如何清除;
  • 设备发生 DMA 错误后如何恢复。

这些问题如果没有在硬件和软件之间定义清楚,即使硬件逻辑和软件代码各自没有语法错误,系统仍然无法正常工作。

分析一个底层硬件功能时,可以按照四个问题展开。

第一个问题:硬件当前处于什么状态

例如:

  • CPU 当前处于实地址模式、保护模式还是长模式?
  • A20 地址线当前是否有效?
  • IOMMU 是否已经启用 DMA 重映射?
  • ARMv8-A 当前是否运行在 EL2?
  • Guest 是否已经进入运行状态?

如果软件不了解初始状态,后续配置就可能建立在错误假设上。

第二个问题:软件需要遵守什么契约

软件必须了解:

  • 哪些寄存器可以访问;
  • 哪些位可以修改;
  • 哪些字段是只读或保留位;
  • 地址需要怎样对齐;
  • 表项使用什么格式;
  • 哪些功能必须按照顺序开启;
  • 哪些操作需要内存屏障或 TLB 失效;
  • 错误发生后由谁负责处理。

寄存器手册中的“位定义”只是契约的一部分,完整契约通常还包括访问顺序、状态转换和异常处理方式。


第三个问题:哪个动作真正改变了硬件状态

例如:

  • 向 CR0 写入分页使能位;
  • 设置 IA32_EFER.LME;
  • 通过平台控制接口打开 A20;
  • 启用 IOMMU 的 DMA 重映射;
  • 设置 HCR_EL2.VM;
  • 通过异常返回指令进入 Guest。

硬件不会自动理解软件的最终目标。软件必须通过规定的寄存器、表项或指令,明确告诉硬件应当进入什么状态。


第四个问题:如何验证硬件确实完成了切换

不能因为程序“没有立即崩溃”,就认为配置一定成功。

常见的验证方式包括:

  • 回读寄存器;
  • 访问已知地址;
  • 检查异常入口;
  • 观察 DMA fault;
  • 读取 ESR_EL2、FAR_EL2 等异常寄存器;
  • 对设备执行受控测试;
  • 使用调试器或模拟器观察处理器状态。

底层初始化可以抽象为:

代码语言:txt
复制
读取初始状态
      │
      ▼
检查硬件能力
      │
      ▼
准备表、栈、地址和权限
      │
      ▼
执行寄存器或控制器切换
      │
      ▼
读取状态并验证
      │
      ▼
进入正常运行

因此,底层初始化并不是简单的“给一个寄存器写值”,而是一个完整的状态转换协议。


三、寄存器:软硬件之间的交互接口

严格来说,软件不能修改已经流片完成的寄存器电路。

但是在芯片、固件、驱动和操作系统共同开发时,软件工程师通常会参与寄存器接口设计,包括:

  • 寄存器地址;
  • 字段划分;
  • 复位值;
  • 读写属性;
  • 状态转换;
  • 错误报告;
  • 初始化顺序;
  • 版本兼容策略。

例如,一个 UART 控制寄存器可以设计为:

代码语言:txt
复制
31                       4 3       0
+-------------------------+---------+
|        Reserved         | DMA_EN  |
|                         | RX_EN   |
|                         | TX_EN   |
|                         | UART_EN |
+-------------------------+---------+

软件不仅需要知道每个位的含义,还需要知道:

  • 是否必须先打开时钟;
  • 是否必须先关闭发送和接收;
  • FIFO 是否需要清空;
  • 中断状态是否需要先清除;
  • 修改波特率时是否必须停止设备;
  • 写入配置后是否需要等待硬件确认。

所以,寄存器接口实际上包含两部分:

代码语言:txt
复制
寄存器布局 + 访问时序

只有布局,没有时序,接口仍然是不完整的。

四、x86 启动:软件配置处理器执行模式

接下来使用x86启动来说明硬件与软件的交互过程。

4.1 x86 没有统一的“启动寄存器契约”

讨论 x86 启动时,必须区分几个不同层次:

  1. 处理器复位状态;
  2. BIOS 传统启动;
  3. UEFI 启动;
  4. Multiboot2 等引导协议;
  5. Linux x86 Boot Protocol;
  6. Bootloader 与内核之间的自定义约定。

这些协议规定的入口地址、寄存器内容、段寄存器状态和内存布局并不相同。 处理器复位状态由硬件架构规定,而操作系统实际接收到的启动状态,通常还受到 BIOS、UEFI、Bootloader 和具体启动协议的影响。

硬件复位状态、固件交接状态和操作系统启动代码设置完成后的状态,是三个不同的时刻,不能混为一谈。

4.2 典型的启动协议

Linux x86 32 位启动协议

Linux 32 位保护模式启动协议有自己的入口约定,例如:

代码语言:txt
复制
ESI 指向 struct boot_params
EBP、EDI、EBX 具有协议规定的状态

这些约定只适用于 Linux Boot Protocol,不能推广到 BIOS、UEFI 或其他 Bootloader。

UEFI x64 启动

UEFI x64 镜像入口使用 UEFI 规定的调用约定,常见参数为:

代码语言:txt
复制
RCX = ImageHandle
RDX = EFI_SYSTEM_TABLE*

这不是 Linux x86-64 程序的通用入口约定。

在调用 ExitBootServices() 之后,Boot Services 已经结束,软件不能继续依赖 Boot Services 函数指针。

x86 中几个重要的系统寄存器

寄存器

主要作用

CR0

保护模式、分页、写保护等控制

CR2

保存页错误发生时的线性地址

CR3

指向当前页表根

CR4

PAE、SMEP、SMAP 等扩展功能控制

IA32_EFER

长模式、NX 等扩展功能控制

GDTR

指向全局描述符表

IDTR

指向中断描述符表

这些寄存器不是普通内存变量,需要使用处理器规定的指令访问。

例如,设置页表根:

代码语言:asm
复制
mov cr3, eax

真实代码必须根据当前执行模式和寄存器宽度选择正确的汇编形式。

读取和修改 IA32_EFER 通常使用 MSR 指令:

代码语言:asm
复制
mov ecx, 0xC0000080    ; IA32_EFER
rdmsr

; 修改 EDX:EAX 中需要设置的字段

wrmsr

真实代码还需要处理:

  • 寄存器宽度;
  • 保留位;
  • 地址对齐;
  • 物理地址宽度;
  • CPU 特性检测;
  • 序列化和屏障要求。

需要注意,CR3 中保存的是符合架构要求的页表根物理地址。在支持 PCID 等功能时,低位还可能包含额外控制信息,因此不能无条件地把整个 CR3 当成一个简单地址。


4.3 进入 x86-64 长模式的依赖关系

进入长模式不是设置一个位就结束了,而是多个条件共同满足的结果。

如果当前已经处于保护模式,典型准备过程可以表示为:

代码语言:txt
复制
检查 CPU 是否支持长模式
          │
          ▼
准备 GDT 和 64 位代码段
          │
          ▼
准备页表,映射代码、栈和必要数据
          │
          ▼
确保 CR0.PE = 1
          │
          ▼
设置 CR4.PAE = 1
          │
          ▼
把 PML4 或 PML5 根的物理地址写入 CR3
          │
          ▼
设置 IA32_EFER.LME = 1
          │
          ▼
设置 CR0.PG = 1
          │
          ▼
远跳转到 64 位代码段
          │
          ▼
进入 IA-32e 的 64-bit submode

下面是一个概念化伪汇编,仅用于说明寄存器依赖关系:

代码语言:asm
复制
; [概念伪汇编]
; 前提:
; 1. 已通过 CPUID 检查长模式支持
; 2. GDT 已准备好
; 3. 页表已映射当前代码、栈和必要数据
; 4. page_table_root 是满足对齐要求的物理地址
; 5. 当前环境已经处于或即将进入保护模式

; 如果当前仍处于实地址模式,
; 需要先按照当前启动协议建立保护模式环境。
; 具体过程此处省略。

; 开启 PAE
mov eax, cr4
or  eax, CR4_PAE
mov cr4, eax

; 设置页表根
mov eax, page_table_root
mov cr3, eax

; 设置 IA32_EFER.LME
mov ecx, IA32_EFER
rdmsr
or  eax, EFER_LME
wrmsr

; 开启分页
mov eax, cr0
or  eax, CR0_PG
mov cr0, eax

; 通过远跳转装载 64 位代码段
jmp CODE64_SELECTOR:long_mode_entry

这段代码省略了保护模式建立、GDT 装载、页表构造和异常路径,不能直接作为启动代码使用。

如果当前仍处于实地址模式,则需要先建立保护模式环境。典型过程包括:

代码语言:txt
复制
准备 GDT
   │
   ▼
加载 GDTR
   │
   ▼
设置 CR0.PE
   │
   ▼
远跳转到保护模式代码段

五、A20 地址线:历史兼容问题中的软件兜底

5.1 8086 的地址回绕

早期 Intel 8086/8088 处理器只有 20 根地址线,即 A0 到 A19,可以访问:

代码语言:txt
复制
2^20 = 1 MiB

的地址空间。

但是,8086 使用段地址和偏移量计算地址,计算结果可能超过 20 位。

例如:

代码语言:txt
复制
段地址:0xFFFF
偏移量:0x0010

计算结果为:

代码语言:txt
复制
0xFFFF × 16 + 0x0010 = 0x100000

由于处理器只有 A0 到 A19,超出的高位无法输出,于是发生回绕:

代码语言:txt
复制
0x100000 → 0x00000

因此,早期系统中以下两个地址可能指向同一个位置:

代码语言:txt
复制
0x000000
0x100000

这是一种由硬件地址线宽度导致的行为。


5.2 80286 带来的兼容性问题

80286 增加了第 21 个物理地址位,也就是 A20,可以访问 1 MiB 以上的地址。

从新硬件的角度看,这是地址能力的提升;但部分旧软件依赖 8086 的地址回绕行为。

为了兼容旧软件,IBM PC/AT 等早期平台通过外部逻辑增加了 A20 Gate:

代码语言:txt
复制
A20 关闭:屏蔽物理地址 bit 20
A20 打开:允许物理地址 bit 20 正常参与地址生成

这里需要区分三个概念:

  • A20 line:物理地址的 bit 20;
  • A20 gate:用于屏蔽或放行 A20 的兼容逻辑;
  • A20M#:后来部分处理器和平台使用的低有效控制信号。

它们不是同一个 CPU 控制寄存器。

还需要注意,A20 关闭并不意味着“所有超过 1 MiB 的地址都简单地对 1 MiB 取模”。

更准确地说,是地址的 bit 20 被强制为 0。因此,下面两类地址可能发生别名:

代码语言:txt
复制
地址 bit 20 不同
其他地址位相同

典型例子是:

代码语言:txt
复制
0x000000 与 0x100000

但不能把所有高地址都简单概括为对 1 MiB 取模。

因此,在早期开发中,软件需要实现队相关硬件的特殊化处理,一个优秀的操作系统应该能够适配大部分主流硬件,完成这些是软件工程师的责任

5.3 常见的 A20 启用路径

历史上常见的路径包括:

  1. BIOS 接口,例如 INT 15h, AX=2401h;
  2. 8042 键盘控制器;
  3. 系统控制端口 0x92 的 Fast A20;
  4. 芯片组、固件或虚拟机平台提供的兼容接口。

通过 8042 控制器时,软件通常需要按照类似步骤操作:

代码语言:txt
复制
等待控制器输入缓冲区空闲
          │
          ▼
向 0x64 发送命令
          │
          ▼
从 0x60 读取或写入数据
          │
          ▼
修改输出端口中的 A20 控制位
          │
          ▼
等待硬件完成

传统 8042 路径通常会涉及:

代码语言:txt
复制
向 0x64 发送输出端口命令
从 0x60 读取输出端口值
保留其他位
设置输出端口的 A20 位
将新值写回 0x60

具体命令、状态位和时序必须按照目标平台实现处理,并且必须带有超时机制。

使用端口 0x92 时也不能简单写入一个固定值,因为端口的其他位可能具有不同功能。某些平台中 bit 0 可能与快速复位有关,错误修改可能导致系统复位。

因此,A20 处理流程通常是:

代码语言:txt
复制
查询当前状态
      │
      ├── 已经开启:直接继续
      │
      ├── 尝试平台推荐路径
      │
      ├── 失败后尝试兼容路径
      │
      └── 通过内存测试确认

不同平台不一定支持所有方式,也不存在对所有机器都适用的唯一顺序。

现代 BIOS、UEFI 和虚拟机固件通常已经打开 A20,但启动代码仍然应根据具体启动环境决定是否需要检测,而不应盲目操作 8042 或端口 0x92。

六、IOMMU

6.1 IOMMU 的基本工作方式

IOMMU,即 Input-Output Memory Management Unit,输入输出内存管理单元,通常位于设备和内存之间。

没有 IOMMU 时:

代码语言:txt
复制
设备 DMA 地址
      │
      ▼
直接访问内存

有 IOMMU 时:

代码语言:txt
复制
设备 DMA 地址 / IOVA
          │
          ▼
根据设备身份选择 domain
          │
          ▼
      IOMMU 查表
          │
          ├── 地址和权限合法
          │       │
          │       ▼
          │    主机物理地址
          │
          └── 非法访问
                  │
                  ▼
              阻断并报告 fault

IOMMU 处理的不是 CPU 虚拟地址,而是设备发起的 DMA 地址,通常称为:

  • IOVA;
  • DMA address;
  • Bus address;
  • Device virtual address。

IOMMU 通常根据以下信息进行处理:

  • 设备身份;
  • PCIe Requester ID 或其他平台设备标识;
  • 所属 translation domain;
  • DMA 页表;
  • 访问权限;
  • 地址范围。

常见实现包括:

  • Intel VT-d;
  • AMD-Vi;
  • Arm SMMU。

CPU MMU 主要处理:

代码语言:txt
复制
CPU 虚拟地址 → 物理地址

IOMMU 主要处理:

代码语言:txt
复制
设备 IOVA / DMA 地址 → 主机物理地址

两者设计思想类似,但页表格式、失效机制、错误语义和访问者身份并不完全相同。


6.2 不同 IOMMU 实现的差异

不同厂商的 IOMMU 具有相似的目标,但具体硬件结构并不相同。

Intel VT-d

常见概念包括:

代码语言:txt
复制
PCIe Requester ID
        │
        ▼
Root Table
        │
        ▼
Contxt Table
        │
        ▼
Translation Domain
        │
        ▼
DMA 页表

AMD-Vi

AMD IOMMU 使用自己的设备标识、设备表项、域和页表结构,不能直接套用 Intel VT-d 的 Root Table 或 Contxt Table 格式。

Arm SMMU

Arm SMMU 常见的概念包括:

代码语言:txt
复制
StreamID
    │
    ▼
Stream Table Entry
    │
    ▼
上下文描述符
    │
    ▼
Stage-1 / Stage-2 转换

因此,不能把某一种 IOMMU 的寄存器名称和表项格式直接套用到另一种实现上。

本文只讨论基本的 DMA remapping 模型:

代码语言:txt
复制
设备身份 → 隔离域 → IOVA → 物理地址

对于 ATS、PASID、PRI、SVA、SR-IOV 等扩展,需要结合具体规范单独分析。


6.3 IOMMU 中的软件职责

IOMMU 不是打开一个总开关就结束了。软件通常需要完成:

  1. 探测平台是否支持 IOMMU;
  2. 读取 ACPI、设备树和硬件拓扑;
  3. 识别设备身份;
  4. 创建设备对应的 translation domain;
  5. 建立 DMA 页表;
  6. 设置读写权限;
  7. 将设备绑定到对应 domain;
  8. 刷新 IOTLB 或相关翻译缓存;
  9. 处理 DMA fault;
  10. 在设备停止后撤销映射。

以 PCIe 设备为例,可以抽象为:

代码语言:txt
复制
Requester ID
      │
      ▼
设备上下文
      │
      ▼
Translation Domain
      │
      ▼
DMA 页表
      │
      ▼
IOVA → 物理地址

核心思想是:

软件建立映射和权限,硬件在每一次 DMA 请求到来时执行检查和转换。


6.4 一个 DMA 映射示例

假设驱动希望让设备访问一块缓冲区:

代码语言:txt
复制
设备可见地址:0x40000000
物理地址:    0x12300000
长度:        64 KiB
权限:        设备可读、设备可写

软件建立的映射可以表示为:

代码语言:txt
复制
IOVA 0x40000000
       │
       ▼
PA   0x12300000
       │
       └── 允许读写,长度 64 KiB

概念性接口如下:

代码语言:c
复制
/*
 * [概念伪代码]
 *
 * 这里假设 buffer 对应一段可用于 DMA 的物理内存。
 * 真实系统可能面对非连续页、IOMMU 页粒度、缓存一致性、
 * DMA mask、并发控制和设备描述符格式等问题。
 */
dma_addr_t map_dma_buffer(device_t *dev,
                          void *buffer,
                          size_t size,
                          dma_direction_t direction)
{
    phys_addr_t pa = get_physical_address(buffer);
    iommu_domain_t *domain = get_device_domain(dev);

    iommu_map(domain,
              IO_VA_BASE,
              pa,
              size,
              direction_to_permission(direction));

    iommu_flush_iotlb(domain, IO_VA_BASE, size);

    return IO_VA_BASE;
}

设备使用 DMA 缓冲区的生命周期通常是:

代码语言:txt
复制
停止设备 DMA
   │
   ▼
分配内存
   │
   ▼
建立 IOMMU 映射
   │
   ▼
执行 CPU/设备缓存同步
   │
   ▼
把 IOVA 写入设备描述符
   │
   ▼
允许 bus mastering
   │
   ▼
启动设备 DMA
   │
   ▼
等待设备完成
   │
   ▼
同步数据回 CPU
   │
   ▼
停止设备并解除映射

关键原则是:

必须先建立正确映射并完成相关失效与同步,再允许设备发起 DMA。

撤销映射时则相反:

代码语言:txt
复制
停止提交新的 DMA
       │
       ▼
等待在途 DMA 完成
       │
       ▼
禁止 bus mastering
       │
       ▼
撤销映射
       │
       ▼
执行必要的 IOTLB/设备侧缓存失效

七、ARMv8-A 硬件虚拟化:从软件模拟到硬件辅助

这里需要会一点虚拟化知识,可以看我之前的帖子学习虚拟化知识。

7.1 ARMv8-A 虚拟化简介

ARMv7-A 时代已经出现了 Virtualization Extensions,并提供了 Hyp mode 等虚拟化能力。

Armv8-A 则将虚拟化能力带入 AArch64 执行状态,通常使用 EL2 表示 Hypervisor 所处的异常级别。

因此,更准确的说法是:

ARMv8-A 进一步完善并扩展了 ARM 平台的硬件虚拟化能力,而不是第一次出现硬件虚拟化。

不同架构版本和处理器实现支持的功能可能不同,例如:

  • Armv8.1-A 引入了 VHE 等能力;
  • 后续架构版本继续增强了嵌套虚拟化和安全虚拟化能力;
  • 具体芯片是否实现某项扩展,需要读取 ID 寄存器或查看产品手册。

7.2 为什么需要硬件

假设 Guest OS 认为自己运行在最高权限级别,它可能执行:

  • 修改页表;
  • 访问系统控制寄存器;
  • 配置中断控制器;
  • 读取定时器;
  • 执行异常返回;
  • 修改内存属性;
  • 访问设备寄存器。

如果完全依靠软件模拟,Hypervisor 可能需要:

  1. 捕获 Guest 的特权操作;
  2. 解码相关指令;
  3. 模拟寄存器效果;
  4. 模拟页表变化;
  5. 模拟中断和定时器;
  6. 模拟设备访问。

硬件辅助虚拟化的目标是:

让 Guest 的普通指令直接在处理器上运行,只在真正需要 Hypervisor 处理的操作上产生陷入。

这与“完全不需要软件”是两回事。

硬件主要负责:

  • 权限检查;
  • 地址转换;
  • 异常陷入;
  • 部分中断状态;
  • 虚拟定时器;
  • 虚拟 CPU 状态切换。

软件仍然负责:

  • 创建虚拟机;
  • 分配内存;
  • 设置页表;
  • 管理设备;
  • 处理陷入;
  • 调度 vCPU;
  • 维护 Guest 生命周期。

7.3 ARMv8-A 的异常级别

在 ARMv8-A 中,可以用异常级别描述不同的软件权限:

代码语言:txt
复制
EL0:普通应用程序
EL1:操作系统内核
EL2:Hypervisor
EL3:安全监控程序或固件环境

可以抽象为:

代码语言:txt
复制
+--------------------------+
| EL0  用户应用             |
+--------------------------+
| EL1  Guest OS / Host OS   |
+--------------------------+
| EL2  Hypervisor           |
+--------------------------+
| EL3  Secure Monitor       |
+--------------------------+

需要注意:

  • EL2 是硬件提供的异常级别,不是普通函数调用层;
  • EL2 不能简单等同于 x86 的某个 Ring;
  • 当前异常级不一定总是 EL2,实际启动时可能从 EL3、EL2 或其他环境交接;
  • 是否实现 EL2、EL3 以及具体虚拟化扩展,要根据处理器实现和启动环境确认;
  • Secure EL2 等能力还取决于具体架构扩展。

当 Guest 执行某些受限制的操作时,硬件可以根据 HCR_EL2 等控制设置,将控制权转移到 EL2:

代码语言:txt
复制
Guest 执行敏感操作
          │
          ▼
硬件检查虚拟化控制
          │
          ├── 允许直接执行
          │
          └── 陷入 EL2
                  │
                  ▼
            Hypervisor 处理

但 EL2 并不会自动完成所有虚拟化工作。Hypervisor 仍然需要配置陷入策略、保存上下文并实现设备模型。

7.4 如何地址转换

在常见的 Guest 虚拟化模型中,Guest OS 负责第一阶段地址转换:

代码语言:txt
复制
Guest 虚拟地址 GVA
          │
          ▼
Guest Stage-1 页表
          │
          ▼
中间物理地址 IPA

Hypervisor 负责第二阶段地址转换:

代码语言:txt
复制
中间物理地址 IPA
          │
          ▼
Hypervisor Stage-2 页表
          │
          ▼
真实物理地址 PA

完整路径为:

代码语言:txt
复制
GVA ──Stage-1──> IPA ──Stage-2──> PA

其中:

  • GVA 是 Guest 内核或应用使用的虚拟地址;
  • IPA 是 Guest 认为的物理地址;
  • PA 是真实内存系统最终使用的物理地址。

例如,Guest 认为自己访问:

代码语言:txt
复制
IPA:0x00000000

Hypervisor 可以将它映射到:

代码语言:txt
复制
PA:0x80000000

Guest 不需要知道真实内存位于哪个物理地址。

Stage-2 相关寄存器通常包括:

  • VTTBR_EL2:Stage-2 页表根和 VMID 相关信息;
  • VTCR_EL2:Stage-2 粒度、输入地址大小和其他转换参数;
  • HCR_EL2.VM:Stage-2 地址转换相关控制;
  • HCR_EL2.RW:较低异常级执行 AArch64 还是 AArch32。

HCR_EL2.VM 和 HCR_EL2.RW 不是同一个功能。

如果 Stage-2 没有开启,就不能继续声称存在完整的 IPA → PA 二次转换。

Stage-1 和 Stage-2 的权限共同决定最终访问结果:

代码语言:txt
复制
最终访问权限
    = Stage-1 允许的权限
      与 Stage-2 允许的权限的交集

Stage-2 可以进一步拒绝 Guest 访问,但不能把 Stage-1 已经拒绝的访问重新放行。

VMID 主要用于翻译缓存上下文标识,不单独构成完整的安全边界。修改页表、VMID 或转换控制后,还需要按照架构要求完成屏障和 TLB 失效。

如果 Guest 访问了没有建立 Stage-2 映射的 IPA:

代码语言:txt
复制
Guest 访问非法 IPA
          │
          ▼
Stage-2 转换失败
          │
          ▼
产生异常并陷入 EL2
          │
          ▼
Hypervisor 读取故障信息
          │
          ▼
决定映射、注入异常或终止 Guest

Hypervisor 可以根据策略:

  • 分配新的物理页;
  • 建立内存映射;
  • 注入 Guest 页错误;
  • 模拟设备;
  • 记录安全事件;
  • 终止虚拟机。

Stage-2 是硬件执行的地址转换机制,但页表内容和权限策略由软件建立。

修改页表、VMID 或转换控制后,还需要按照架构要求完成:

  • 数据同步屏障;
  • 指令同步屏障;
  • TLB 失效;
  • 必要的缓存维护。

下面是armv8虚拟化的重要寄存器

寄存器

作用

VBAR_EL2

EL2 异常向量基地址

HCR_EL2

虚拟化控制和异常路由

VTCR_EL2

Stage-2 转换控制

VTTBR_EL2

当前虚拟机 Stage-2 页表根和 VMID

SCTLR_EL2

EL2 自身 MMU 和缓存控制

ESR_EL2

异常综合信息

FAR_EL2

故障相关虚拟地址

HPFAR_EL2

某些场景下的故障 IPA 信息

ELR_EL2

异常返回地址

SPSR_EL2

异常返回状态

不同 ARMv8-A 版本和处理器实现支持的字段可能不同。不能把某组固定十六进制值当作所有 ARM 处理器的通用配置。

还要特别注意:

  • VTCR_EL2 是 Stage-2 转换控制参数,不是页表根地址;
  • VTTBR_EL2 才包含 Stage-2 页表根相关信息;
  • SCTLR_EL2.M 控制 EL2 自身翻译环境,与 Guest 的 Stage-2 不是同一个开关;
  • CurrentEL 是状态寄存器,不能通过写入它来切换异常级别;
  • 从 EL3 进入 EL2 或从 EL2 进入 Guest,需要准备异常返回状态并执行 ERET。

下面是一个概念伪代码,用于说明初始化依赖,不是可直接运行的 Hypervisor:

代码语言:c
复制
/*
 * [概念伪代码]
 *
 * 省略:
 * - 当前异常级和执行状态检查
 * - 寄存器字段计算
 * - Stage-2 页表格式
 * - 页表项地址宽度和对齐检查
 * - TLB 失效
 * - 数据和指令屏障
 * - 缓存维护
 * - 异常向量保存和恢复
 * - 多核同步
 * - Guest 完整上下文
 */
void hypervisor_init(vm_t *vm)
{
    /* 1. 先建立 EL2 栈和异常向量 */
    write_sysreg(VBAR_EL2, el2_exception_vector);
    setup_el2_stack();

    /* 2. 准备 Guest 的 Stage-2 页表 */
    stage2_map(vm->stage2_root,
               guest_ipa_start,
               host_pa_start,
               guest_memory_size,
               STAGE2_READ | STAGE2_WRITE);

    /* 3. 配置 Stage-2 转换参数 */
    write_sysreg(VTCR_EL2,
                 build_vtcr_for_platform());

    /* 4. 设置当前虚拟机的 Stage-2 页表根 */
    write_sysreg(VTTBR_EL2,
                 build_vttbr(vm->vmid, vm->stage2_root));

    /* 5. 配置 Guest 的异常和特权行为 */
    write_sysreg(HCR_EL2,
                 build_hcr_policy(vm));

    /* 6. 真实实现还需要 TLBI、DSB、ISB 等架构要求的操作 */
    translation_sync_barrier();
}

允许 Guest 运行之前,至少应该确保以下资源已经可用:

代码语言:txt
复制
EL2 栈
   │
   ▼
EL2 异常向量
   │
   ▼
Stage-2 页表
   │
   ▼
Guest 初始寄存器状态
   │
   ▼
虚拟中断和虚拟定时器
   │
   ▼
异常处理和 Guest 上下文保存

启动Guest时,Hypervisor 需要为 Guest 准备:

  • 入口 PC;
  • 栈指针;
  • SPSR_EL2;
  • Guest 的系统寄存器;
  • Guest 的通用寄存器;
  • Stage-2 页表;
  • 异常向量;
  • 虚拟中断和定时器状态。

概念上可以表示为:

代码语言:c
复制
/*
 * [概念伪代码]
 * ERET 是异常返回,不是普通函数返回。
 */
void enter_guest(vm_t *vm)
{
    write_sysreg(ELR_EL2,  vm->entry_pc);
    write_sysreg(SPSR_EL2, vm->initial_pstate);

    restore_guest_system_registers(vm);
    restore_guest_general_registers(vm);

    instruction_sync_barrier();

    /*
     * 真实代码需要使用目标架构规定的异常返回指令。
     */
    eret();
}

Guest 运行后,控制流通常不会像普通函数一样顺序返回,而是在发生以下事件时重新陷入 EL2:

  • 受限系统寄存器访问;
  • WFI 或 WFE;
  • Stage-2 fault;
  • 虚拟设备访问;
  • 外部中断;
  • 未实现或受限制的指令;
  • 需要 Hypervisor 调度的事件。

八、虚拟中断和虚拟定时器

8.1 虚拟中断

Guest OS 必须能够看到自己的中断,否则无法正常调度任务和处理设备事件。

GIC 的虚拟化扩展可以帮助 Hypervisor 管理:

  • 虚拟中断状态;
  • 虚拟中断优先级;
  • 虚拟 CPU 的中断接口;
  • 物理中断到虚拟中断的注入。

基本路径为:

代码语言:txt
复制
物理设备产生中断
          │
          ▼
Hypervisor 识别设备归属
          │
          ▼
设置虚拟中断状态
          │
          ▼
通过 GIC 虚拟接口呈现给 Guest

硬件负责快速保存和呈现部分中断状态,软件负责决定:

  • 哪个设备属于哪个 Guest;
  • 哪个虚拟 CPU 接收中断;
  • 是否延迟注入;
  • 设备采用直通还是软件模拟;
  • 中断完成后如何清理状态。

GIC 虚拟化并不意味着 Guest 可以无条件直接使用物理 Distributor 或 Redistributor。Guest 对相关寄存器的访问可能需要陷入并由 Hypervisor 模拟。

在 GICv3 等实现中,Hypervisor 可能需要管理类似以下寄存器:

代码语言:txt
复制
ICH_HCR_EL2
ICH_VMCR_EL2
ICH_LR<n>_EL2

但具体实现和寄存器集合取决于 GIC 版本及处理器能力。

还需要注意:

  • HCR_EL2.IMO/FMO 主要用于中断路由;
  • 它们不等于“已经打开 GIC 虚拟化”;
  • 虚拟中断的 pending、active、priority 和 EOI 状态仍需要正确维护;
  • GICv4/GICv4.1 的 vPE 和 direct injection 是额外能力,不能推广到所有设备和所有平台。

8.2 虚拟定时器

操作系统依赖定时器进行:

  • 任务调度;
  • 时间片轮转;
  • 超时处理;
  • 定时唤醒;
  • 时间统计。

ARMv8-A 提供虚拟定时器相关能力,例如:

  • CNTVCT_EL0:虚拟计数器;
  • CNTV_CTL_EL0:虚拟定时器控制;
  • CNTV_CVAL_EL0:绝对虚拟比较值;
  • CNTV_TVAL_EL0:相对虚拟计时值;
  • CNTVOFF_EL2:虚拟计数器偏移。

虚拟计数器可以抽象为:

代码语言:txt
复制
Guest 看到的虚拟时间
    ≈ 物理计数器时间 - CNTVOFF_EL2

在符合具体架构定义的计数器宽度和回绕规则下,通常有类似关系:

代码语言:txt
复制
CNTVCT_EL0 = CNTPCT_EL0 - CNTVOFF_EL2

没有硬件虚拟定时器时:

代码语言:txt
复制
Guest 读取时间
      │
      ▼
陷入 Hypervisor
      │
      ▼
软件计算虚拟时间
      │
      ▼
返回 Guest

有硬件虚拟定时器时:

代码语言:txt
复制
Guest 可以直接读取虚拟计数器

硬件减少了频繁陷入的开销,但 Hypervisor 仍然需要管理:

  • 虚拟时间偏移;
  • 虚拟定时器中断;
  • vCPU 调度;
  • Guest 暂停和恢复后的时间语义;
  • vCPU 切换时相关寄存器状态。

定时器到期也不等于 Guest 一定立刻收到中断。还需要:

  1. 定时器控制状态允许产生事件;
  2. 中断通过 GIC 虚拟接口呈现;
  3. Hypervisor 或硬件完成虚拟中断注入;
  4. Guest 侧中断控制器配置正确。

因此,虚拟定时器是硬件辅助功能,而不是完全脱离软件的自动功能。


九、软硬件协同设计中的工程原则

先定义接口,再实现功能

寄存器、DMA 描述符、页表和虚拟机上下文,本质上都是软硬件之间的接口。

接口设计时应明确:

  • 字段含义;
  • 默认值;
  • 对齐要求;
  • 地址宽度;
  • 访问权限;
  • 生命周期;
  • 并发规则;
  • 错误状态;
  • 版本兼容方式。

先建立异常路径,再开放强能力

在开启分页、IOMMU 或虚拟化之前,应先准备能够处理故障的路径:

代码语言:txt
复制
准备异常向量
      │
      ▼
准备异常栈
      │
      ▼
准备故障日志
      │
      ▼
建立页表和权限
      │
      ▼
执行失效和同步
      │
      ▼
再开启地址转换或虚拟化

否则,一旦硬件按预期产生异常,而软件没有可用的异常入口,系统可能直接死机或产生数据损坏。


地址转换和权限必须一起设计

不能只设计地址映射,还要同时回答:

代码语言:txt
复制
谁可以访问?
访问哪个地址?
访问权限是什么?
映射何时生效?
映射何时撤销?
TLB/IOTLB 是否需要失效?
出现错误后如何恢复?

例如,IOMMU 如果只配置了地址,却没有正确绑定设备身份,就不能形成完整的 DMA 隔离。

Stage-2 如果只映射地址,却没有设置正确的读写权限,也不能形成完整的 Guest 内存保护。


能力探测属于功能的一部分

底层代码不能假设所有 CPU、芯片组和虚拟机环境都相同。

软件应当通过适当机制探测:

  • x86 的 CPUID;
  • ARM 的 ID 寄存器;
  • ACPI 表;
  • 设备树;
  • PCIe 配置空间;
  • 固件启动协议;
  • IOMMU/SMMU 能力。

只有确认硬件能力后,才能决定是否启用对应功能。


为关键功能准备降级路径

硬件能力

首选方案

降级方案

A20 控制

平台推荐路径

8042 或兼容路径

IOMMU

硬件 DMA 隔离

受限内存池或 Bounce Buffer

虚拟定时器

硬件虚拟定时器

Hypervisor 软件管理

虚拟中断

GIC 硬件虚拟化

软件注入或模拟

设备虚拟化

设备直通

软件设备模型

CPU 虚拟化

硬件陷入和二阶段转换

软件模拟或更严格限制

降级方案通常性能较低,但可以提升系统兼容性和可移植性。


把高频机制交给硬件,把复杂策略交给软件

适合放到硬件中的功能通常具有以下特点:

  • 执行频率高;
  • 规则相对稳定;
  • 对延迟敏感;
  • 需要在数据通路上完成;
  • 需要强制执行。

例如:

  • 地址转换;
  • 权限检查;
  • DMA 搬运;
  • 中断状态保存;
  • 定时器比较;
  • Guest 访问陷入;
  • TLB 或 IOTLB 查询。

适合放到软件中的功能通常具有以下特点:

  • 策略变化频繁;
  • 需要动态分配资源;
  • 需要兼容多个版本;
  • 需要复杂的故障恢复;
  • 需要根据系统负载作出决策。

例如:

  • 内存分配;
  • 设备归属;
  • vCPU 调度;
  • Guest 生命周期管理;
  • 错误恢复;
  • 设备模型;
  • 兼容路径。

可以概括为:

硬件实现机制,软件实现策略。

从 x86 启动阶段配置 CR0、CR3、CR4 和 IA32_EFER,到 A20 地址线的历史兼容;从 IOMMU 对 DMA 地址进行硬件隔离,到 ARMv8-A 通过 EL2、Stage-2 页表、虚拟中断和虚拟定时器支持高效虚拟化,这些技术虽然来自不同处理器和不同发展阶段,但它们遵循着相同的系统设计思想:

代码语言:txt
复制
硬件提供能力
软件建立规则
硬件执行高频路径
软件管理资源和异常

因此,软硬件协同设计的核心不是简单决定“功能放在硬件还是软件”,而是回答以下问题:

代码语言:txt
复制
谁生成地址?
谁检查权限?
谁保存状态?
什么时候发生切换?
如何确认切换生效?
出现错误后谁负责恢复?

真正优秀的底层初始化,不是把硬件简单“打开”,而是在打开之前准备好硬件将要遵守的规则,并在打开之后证明这些规则确实生效。

把高频、低延迟、强隔离的机制交给硬件;把灵活、复杂、需要策略判断的部分交给软件。undefined这就是嵌入式系统中软硬件协同设计的核心价值。


参考资料

  1. Intel 64 and IA-32 Architectures Software Developer’s Manual
  2. Linux x86 Boot Protocol
  3. GNU GRUB Multiboot2 Specification
  4. UEFI Specification
  5. Intel VT-d Architecture Specification
  6. AMD IOMMU Specification
  7. Arm Architecture Reference Manual for A-profile architecture
  8. Arm Learn the Architecture:Armv8-A Virtualization
  9. Arm Generic Timer
  10. GIC Architecture Specification
  11. Linux Kernel Documentation:DMA API、IOMMU、KVM
  12. QEMU、Linux KVM 以及相关架构启动代码实现

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、引言
  • 二、什么是软硬件协同设计
    • 2.1 硬件与软件不是简单的上下游关系
      • 第一个问题:硬件当前处于什么状态
      • 第二个问题:软件需要遵守什么契约
      • 第三个问题:哪个动作真正改变了硬件状态
      • 第四个问题:如何验证硬件确实完成了切换
  • 三、寄存器:软硬件之间的交互接口
  • 四、x86 启动:软件配置处理器执行模式
    • 4.1 x86 没有统一的“启动寄存器契约”
    • 4.2 典型的启动协议
      • Linux x86 32 位启动协议
      • UEFI x64 启动
      • x86 中几个重要的系统寄存器
    • 4.3 进入 x86-64 长模式的依赖关系
  • 五、A20 地址线:历史兼容问题中的软件兜底
    • 5.1 8086 的地址回绕
    • 5.2 80286 带来的兼容性问题
    • 5.3 常见的 A20 启用路径
  • 六、IOMMU
    • 6.1 IOMMU 的基本工作方式
    • 6.2 不同 IOMMU 实现的差异
      • Intel VT-d
      • AMD-Vi
      • Arm SMMU
    • 6.3 IOMMU 中的软件职责
    • 6.4 一个 DMA 映射示例
  • 七、ARMv8-A 硬件虚拟化:从软件模拟到硬件辅助
    • 7.1 ARMv8-A 虚拟化简介
    • 7.2 为什么需要硬件
    • 7.3 ARMv8-A 的异常级别
    • 7.4 如何地址转换
  • 八、虚拟中断和虚拟定时器
    • 8.1 虚拟中断
    • 8.2 虚拟定时器
  • 九、软硬件协同设计中的工程原则
    • 先定义接口,再实现功能
    • 先建立异常路径,再开放强能力
    • 地址转换和权限必须一起设计
    • 能力探测属于功能的一部分
    • 为关键功能准备降级路径
    • 把高频机制交给硬件,把复杂策略交给软件
  • 参考资料
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档