内存布局
计算机组成
首先我们回顾计算机的组成:
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文件的内存布局。
最后更新于