第一节 基本概念 - 地址空间

地址空间是内核与应用的生存活动空间。内核或应用在运行中需要访问资源,主要包括三类资源:内存、I/O 端口以及中断编号(irq num)。具体到 RiscV 平台,前两类资源是统一进行编址的,它们在地址空间中由唯一的地址范围来标识;至于第三类 - 中断编号,本书不把它归入地址空间的范畴,我们将在第六章再来讨论。

对于 X86 体系结构,内存与 I/O 端口分别编址,需要通过不同的指令来访问这两类资源。

我们目前开发的内核是 Unikernel 形态,地址空间方面有其明显的特点:

  • 只有单一的处于内核态的地址空间,内核与内嵌应用共用这一地址空间,不存在用户态进程空间(Unikernel其实连进程概念都没有)。
  • 当内核刚启动时,这个地址空间是物理地址空间,空间布局由硬件平台所决定。
  • 基于分页机制,我们可以在物理地址空间的基础上,新增虚拟地址空间,以获得更好的地址管理的灵活性。
物理与虚拟地址空间

CPU 有一个附属部件 MMU(Memory Management Unit),它的作用就是控制分页机制。MMU 默认是未启用分页的,所以内核启动时,直接看到的就是物理地址空间,并通过物理地址来访问资源,我们目前就正处在这个状态;然后内核创建页表,自定义虚拟地址空间的布局,然后通过设置 MMU 启用分页,分页启用后,虚拟地址空间开始发挥作用,它向内核遮蔽了真实的物理地址空间,内核看到的是经过精心映射后的虚拟地址空间,通过虚拟地址访问资源。虚拟地址空间中的各个地址范围,一部分会被实际映射到物理空间的某些地址区间上,而大部分会暂时或者永远处于映射的状态,直接访问时将导致 Page Fault 异常。

物理地址空间是硬件平台生产构造时就已经确定的,而虚拟地址空间则是内核可以根据实际需要灵活定义和实时改变的,这是将来内核很多重要机制的基础。按照近年来流行的说法,分页机制赋予了内核“软件定义”地址空间的能力。

以下就针对我们的实验平台 qemu-riscv64-virt,进行分析和实验。

物理地址空间由硬件平台在生产时决定,通常以 FDT(flattened device tree)的形式提供。对于 qemu-riscv64-virt 平台,我们可以通过如下方式导出它的 fdt 文件。

当运行 make run 时,在屏幕输出中可以看到 qemu 的执行命令行:

Running on qemu...

qemu-system-riscv64 -m 128M -smp 1 -machine virt -bios default
-kernel target/riscv64gc-unknown-none-elf/release/axorigin.bin -nographic
-D qemu.log -d in_asm

注意上图中显示的 qemu-system-riscv64 执行时的命令行,修改这行命令,在参数 "-machine virt" 后面追加 ",dumpdtb=virt.dtb"。如此,命令的执行效果就发生了改变,不再是启动模拟器,而是导出名为 virt.dtb 的 fdt 文件,然后就退出了。得到的 virt.dtb 还只是二进制格式,进一步用 dtc 工具把它转换成可读的文本形式。执行的命令如下:

# 在参数"-machine virt"之后, 增加 ",dumpdtb=virt.dtb",用于导出fdt文件的二进制格式dtb
qemu-system-riscv64 -m 128M -smp 1 -machine virt,dumpdtb=virt.dtb \
	-bios default -kernel target/riscv64gc-unknown-none-elf/release/axorigin.bin -nographic

# 把二进制形式virt.dtb,转化为可读的文本形式
dtc ./virt.dtb -o ./virt.dts

注意:执行上面的命令行导出 fdt 时,一定要保留所有参数。fdt 中很多配置项的值由 qemu 根据参数来决定。例如,对于物理内存的大小,qemu 参数指定 -m 128M 来模拟 128M 的物理内存,相应的 fdt 中描述的 memory 就是 128M。

现在查看 virt.dts 的内容,仅节选与下步实验密切相关的部分:

/dts-v1/;

/ {
    #address-cells = <0x02>;
    #size-cells = <0x02>;
    compatible = "riscv-virtio";
    model = "riscv-virtio,qemu";
	... ...
    memory@80000000 {
        device_type = "memory";
        reg = <0x00 0x80000000 0x00 0x8000000>;
    };
    ... ...
    soc {
        #address-cells = <0x02>;
        #size-cells = <0x02>;
        compatible = "simple-bus";
        ranges;
  		... ...
        serial@10000000 {
            interrupts = <0x0a>;
            interrupt-parent = <0x03>;
            clock-frequency = "\08@";
            reg = <0x00 0x10000000 0x00 0x100>;
            compatible = "ns16550a";
        };
		... ...
        virtio_mmio@10001000 {
            interrupts = <0x01>;
            interrupt-parent = <0x03>;
            reg = <0x00 0x10001000 0x00 0x1000>;
            compatible = "virtio,mmio";
        };
        ... ...
    };
};

现在先跳过 fdt 其它细节,注意 memoryserialvirtio_mmio 三个设备的 reg 字段,该字段描述了对应设备在地址空间中占据的地址区间范围,区间由起始地址和长度组成,是包含四个 32 位数的序列。对序列的解析需要依赖 #address-cells 和 #size-cells,它们如同编程中的变量作用域一样,内层定义可以覆盖外层。上面的示例中,它们分别在根和 soc 两级出现,且数值都是 2,这说明区间起始地址由两个值组成,长度也由两个值组成,它们都是 64 位数。fdt 采用大端序表示数据,所以物理内存 memory 的地址范围从 0x8000_0000 开始,大小是 0x800_0000 即 128M,这与当初在命令行中指定的参数是一致的;串口 serial 从 0x1000_0000 开始,长度 256 字节;第一个 virtio_mmio slot 的地址区间从 0x10001000 开始,长度是 4096 字节即 1 页,后面按顺序排列了另外 7 个 virtio_mmio slot。

现在可以画出我们实验平台大致的物理地址空间布局了:

物理地址空间布局

在第零章,我们提到需要把 SBI 放到物理内存开始的位置 0x8000_0000 地址处,这里就知道了该地址可以从 fdt 中得知。此外,通过 fdt,我们还知道了 qemu 实验平台上,各个设备所占用的 mmio 范围集中安排在物理空间的低地址范围内。其中 virtio mmio slots 是后面实验中操作 virtio 设备的基础信息,我们将在第九章再分析它们。本章暂时只关注内存管理,即先完成内存部分 - 0x8000_0000 之后的虚拟地址空间映射。

从第一章我们知道,内核最早启动时,a1 寄存器保存的就是 fdt 二进制数据块 dtb 的开始指针。这个数据块与我们在本节中导出分析的 fdt 内容完全相同。fdt 是内核获取硬件平台配置信息的主要途径。目前为止,我们只是人工查看导出的 fdt 信息,为内核开发提供基础信息。到下一章的最后一节,我们将为内核实现自动解析 fdt 的功能,让内核在运行中自动获取这些关键信息。

下面就来考虑建立虚拟地址空间的问题。

本章我们建立的虚拟地址空间只需要与物理空间形成简单的线性映射,并且只处理包含 SBI 和 Kernel 存储区的 1G 范围。如下图,虚拟地址范围 0xFFFF_FFC0_8000_0000 ~ 0xFFFF_FFC0_C000_0000 线性映射到物理地址范围 0x8000_0000 ~ 0xC000_0000。

虚拟到物理空间映射

线性偏移的好处是,只需要通过加减偏移运算,就能完成虚拟地址与物理地址之间的转换。这个线性偏移常量称为 PHYS_VIRT_OFFSET,相应的运算公式表示为:

VA = PA + PHYS_VIRT_OFFSET;
PA = VA - PHYS_VIRT_OFFSET;

当前实验中 PHYS_VIRT_OFFSET=0xFFFF_FFC0_0000_0000,与我们选择的分页机制方案 Sv39 有关。我们开发的内核是 64 位系统,最大可用的地址空间范围是 64 位;但是按照 RiscV 规范,Sv39 有效的虚拟地址范围被约束为最大 39 位,从 40 位往上那些未使用的位必须与第 39 位一致。也就是说,如果第 39 位是 1,从第 40 位向上全部填充 1,有效范围 0xFFFF_FFC0_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF;如果第 39 位是 0,高位全部填充 0,那么有效地址范围就是从 0 到 0x3F_FFFF_FFFF。这两个范围正处于64位空间最高端和最低端这两头的位置。按照惯例,我们选择了高端的这个范围,把它作为内核本身的虚拟地址空间;低端的那个范围暂时预留,将来支持宏内核模式时,我们把它作为应用进程的虚拟地址空间。

下面开始基于 Sv39 方案,建立我们内核的分页机制。