内存布局

计算机组成

首先我们回顾计算机的组成:

CPU, 存储设备(粗略地说,包括断电后遗失的内存,和断电后不遗失的硬盘),输入输出设备,总线。

现在我们手里的东西有:QEMU会帮助我们模拟一块riscv64的CPU,一块物理内存,还会借助你的电脑的键盘和显示屏来模拟命令行的输入和输出。虽然QEMU不会真正模拟一堆线缆,但是总线的通信功能也在QEMU内部实现了。

还差什么呢?硬盘。

OpenSBI

我们需要硬盘上的程序和数据。比如崭新的windows电脑里C盘已经被占据的二三十GB空间,除去预装的应用软件,还有一部分是windows操作系统的内核。在插上电源开机之后,就需要运行操作系统的内核,然后由操作系统来管理计算机。

问题在于,操作系统作为一个程序,必须加载到内存里才能执行。而“把操作系统加载到内存里”这件事情,不是操作系统自己能做到的,就好像你不能拽着头发把自己拽离地面。

因此我们可以想象,在操作系统执行之前,必然有一个其他程序执行,他作为“先锋队”,完成“把操作系统加载到内存“这个工作,然后他功成身退,把CPU的控制权交给操作系统。

这个“其他程序”,我们一般称之为bootloader. 很好理解:他负责boot(开机),还负责load(加载OS到内存里),所以叫bootloader.

在QEMU模拟的riscv计算机里,我们使用QEMU自带的bootloader: OpenSBI固件。

知识点

在计算机中,固件(firmware)是一种特定的计算机软件,它为设备的特定硬件提供低级控制,也可以进一步加载其他软件。固件可以为设备更复杂的软件(如操作系统)提供标准化的操作环境。对于不太复杂的设备,固件可以直接充当设备的完整操作系统,执行所有控制、监视和数据操作功能。 在基于 x86 的计算机系统中, BIOS 或 UEFI 是固件;在基于 riscv 的计算机系统中,OpenSBI 是固件。OpenSBI运行在M态(M-mode),因为固件需要直接访问硬件。

RISCV有四种特权级(privilege level)

Level

Encoding

全称

简称

0

00

User/Application

U

1

01

Supervisor

S

2

10

Reserved(目前未使用,保留)

3

11

Machine

M

粗略的分类:

U-mode是用户程序、应用程序的特权级,S-mode是操作系统内核的特权级,M-mode是固件的特权级。

详细内容请自行查阅RISC-v手册。

elf与bin

我们可以想象这样的过程:操作系统的二进制可执行文件被OpenSBI加载到内存中,然后OpenSBI会把CPU的"当前指令指针"(pc, program counter)跳转到内存里的一个位置,开始执行内存中那个位置的指令。

OpenSBI怎样知道把操作系统加载到内存的什么位置?总不能随便选个位置。也许你会觉得可以把操作系统的代码总是加载到固定的位置,比如总是加载到内存地址最高的地方。

问题在于,之后OpenSBI还要把CPU的program counter跳转到一个位置,开始操作系统的执行。如果加载操作系统到内存里的时候随便加载,那么OpenSBI怎么知道把program counter跳转到哪里去呢?难道操作系统的二进制可执行文件需要提供“program counter跳转到哪里"这样的信息?

实际上,操作系统的二进制可执行文件,会指定它自己应该被加载到内存的哪个地址。而OpenSBI会很听话地把二进制可执行文件放到她想去的位置上。但是关于program counter的跳转,OpenSBI是独断专行的,总是会把program counter跳到0x80200000这个内存地址开始执行, 所以故事(版本1)其实是这样的:

OpenSBI: 操作系统, 你到0x8020000等着program counter跳过来执行!

操作系统:好的!请把我加载到xxxxxx这个位置,这样program counter跳过来的时候就不会出问题了。

实际上,二进制程序加载到内存中是一件很精细的工作。一个二进制程序包括很多section, 如text(程序代码),bss(需要初始化为零的数据),rodata(只读数据)。二进制程序的每个section都可以指定一个希望被加载到的内存地址。

故事可以是这样的吗?(版本2)

OpenSBI: 操作系统, 你到0x8020000等着program counter跳过来执行!

操作系统:好的!请把我的text section加载到A位置,data section加载到B位置,rodata section加载到C位置......这样program counter跳过来的时候就不会出问题了!

OpenSBI: 你说啥?

两个版本的故事是因为,我们有两种不同的可执行文件格式:elf(e是executable的意思, l是linkable的意思,f是format的意思)和bin(binary)。

elf文件(wikipedia: elf)比较复杂,包含一个文件头(ELF header), 包含冗余的调试信息,指定程序每个section的内存布局,需要解析program header才能知道各段(section)的信息。如果我们已经有一个完整的操作系统来解析elf文件,那么elf文件可以直接执行。但是对于OpenSBI来说,elf格式还是太复杂了,把操作系统内核的elf文件交给OpenSBI就会发生版本2的悲惨故事。

bin文件就比较简单了,简单地在文件头之后解释自己应该被加载到什么起始位置。OpenSBI可以理解得很清楚,这就是版本1的故事。

我们举一个例子解释elf和bin文件的区别:初始化为零的一个大数组,在elf文件里是bss数据段的一部分,只需要记住这个数组的起点和终点就可以了,等到加载到内存里的时候分配那一段内存。但是在bin文件里,那个数组有多大,有多少个字节的0,bin文件就要对应有多少个零。所以如果一个程序里声明了一个大全局数组(默认初始化为0),那么可能编译出来的elf文件只有几KB, 而生成bin文件之后却有几MB, 这是很正常的。实际上,可以认为bin文件会把elf文件指定的每段的内存布局都映射到一块线性的数据里,这块线性的数据(或者说程序)加载到内存里就符合elf文件之前指定的布局。

那么我们的任务就明确了:得到内存布局合适的elf文件,然后把它转化成bin文件(这一步通过objcopy实现),然后加载到QEMU里运行(QEMU自带的OpenSBI会干这个活)。下面我们来看如何设置elf文件的内存布局。

最后更新于