本节任务:
梳理程序在操作系统中被编译运行的全流程,大体了解我们在没有操作系统的情况下,我们会面对那些困难
重点
1. 计算机组成基础
面对的困难:
没有操作系统,我们必须直面硬件资源,管理起他们并为应用程序提供高效的抽象。
-
计算机主要由 处理器(
CPU
),物理内存和**I/O
外设**三部分组成 -
CPU
唯一能够直接访问的只有物理内存中的数据 -
CPU
通过访存指令访问物理内存中的数据 -
CPU
以多个字节为单位访问物理内存
Tips:
从CPU
的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的是,该下标通常不以 0 开头,而通常以一个常数,如0x80000000
开头。简言之,CPU
可以通过物理地址来寻址,并 逐字节 地访问物理内存中保存的数据。
问题:
因为
CPU
以多个字节为单位访问物理内存,会引发两个值得考虑的问题。
- 字节读取顺序 (大小端序问题)
- 内存地址对齐
2. QEMU
模拟器
使用QEMU模拟器说明
为了方便实验,在此用
QEMU
模拟器模拟出一个裸机环境,我们须实现将内核运作在QEMU
中并检验其正确性。本实验使用
qemu-system-riscv64
模拟一台64位Risc-v
(精简指令集)架构的计算机
2.1 启动 QEMU
指令如下
$ qemu-system-riscv64 \-machine virt \-nographic \-bios ../bootloader/rustsbi-qemu.bin \-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
为了方便调用,我们可以将其写入 Makefile
中
# Building
TARGET := riscv64gc-unknown-none-elf
MODE := release
KERNEL_ELF := target/$(TARGET)/$(MODE)/os
KERNEL_BIN := $(KERNEL_ELF).bin
DISASM_TMP := target/$(TARGET)/$(MODE)/asm# BOARD
BOARD := qemu
SBI ?= rustsbi
BOOTLOADER := ../bootloader/$(SBI)-$(BOARD).bin# KERNEL ENTRY
KERNEL_ENTRY_PA := 0x80200000run: run-innerrun-inner: build@qemu-system-riscv64 $(QEMU_ARGS)QEMU_ARGS := -machine virt \-nographic \-bios $(BOOTLOADER) \-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
-machine virt
表示将模拟的 64 位RISC-V
计算机设置为名为virt
的虚拟计算机。我们知道,即使同属同一种指令集架构,也会有很多种不同的计算机配置,比如CPU
的生产厂商和型号不同,支持的I/O
外设种类也不同。Qemu
还支持模拟其他RISC-V
计算机-nographic
表示模拟器不需要提供图形界面,而只需要对外输出字符流。- 通过
-bios
可以设置Qemu
模拟器开机时用来初始化的引导加载程序(bootloader
),这里我们使用预编译好的rustsbi-qemu.bin
,它需要被放在与os
同级的bootloader
目录下,该目录可以从每一章的代码分支中获得。 - 通过虚拟设备
-device
中的loader
属性可以在Qemu
模拟器开机之前将一个宿主机上的文件载入到Qemu
的物理内存的指定位置中,file
和addr
属性分别可以设置待载入文件的路径以及将文件载入到的 Qemu 物理内存上的物理地址。这里我们载入的os.bin
被称为 内核镜像 ,它会被载入到Qemu
模拟器内存的0x80200000
地址处。 那么内核镜像os.bin
是怎么来的呢?上一节中我们移除标准库依赖后会得到一个内核可执行文件os
,将其进一步处理就能得到os.bin
,具体处理流程我们会在后面深入讨论。
2.2 QEMU
启动流程
Tips:
- 在
Qemu
模拟的virt
硬件平台上,物理内存的起始物理地址为0x80000000
,物理内存的默认大小为128MiB
- 上述指令启动
QEMU
,在QEMU
执行任何指令之前,会把两个文件加载到QEMU
的物理内存中
- 作为
bootloader
的rustsbi-qemu.bin
加载到物理内存以物理地址0x80000000
开头的区域上- 把内核镜像
os.bin
加载到以物理地址0x80200000
开头的区域上
QEMU
加电启动有三个阶段,每一个阶段都有一层软件或者是固件负责,承担起计算机的初始化工作。
每当当前阶段执行完毕,就会跳转到下一层软件或固件的入口地址,将计算机的控制权转移给了下一层软件或固件。
2.2.1 启动第一阶段:固化在QEMU
内的汇编程序
- 将必要的文件载入到
Qemu
物理内存之后,Qemu CPU
的程序计数器(PC, Program Counter
)会被初始化为0x1000
Qemu
实际执行的第一条指令位于物理地址0x1000
- 执行寥寥数条指令并跳转到物理地址
0x80000000
对应的指令处并进入第二阶段。
2.2.2 启动第二阶段:bootloader
- 由于
Qemu
的第一阶段固定跳转到0x80000000
,我们需要将负责第二阶段的bootloader rustsbi-qemu.bin
放在以物理地址0x80000000
开头的物理内存中 bootloader
负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口,在Qemu
上即可实现将计算机控制权移交给我们的内核镜像os.bin
- 对于不同的
bootloader
而言,下一阶段软件的入口不一定相同 - 获取进入下一阶段信息的方式和时间点也有所不同
- 入口地址可能是一个预先约定好的固定的值
- 也有可能是在
bootloader
运行期间才动态获取到的值。
- 我们选用的
RustSBI
则是将下一阶段的入口地址预先约定为固定的0x80200000
2.2.3 启动第三阶段:内核镜像
- 为了正确地和上一阶段的
RustSBI
对接,我们需要保证内核的第一条指令位于物理地址0x80200000
处 - 将内核镜像预先加载到
Qemu
物理内存以地址0x80200000
开头的区域上 - 一旦 CPU 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核
2.3 总结
为了让内核镜像能够正确对接到 QEMU
和 RustSBI
上,我们的内核镜像文件必须满足
- 该文件的开头即为内核待执行的第一条指令
- 如果不满足上述条件,我们还需要对可执行文件进行一些操作才能得到可提交给
Qemu
的内核镜像
3. 程序内存布局与编译流程
3.1 程序内存布局
当我们将源代码编译成为可执行文件后,我们就看不大懂了,但是我们知道,至少这些字节可以被分为代码和数据两个部分
-
代码部分:由一条条可以被
CPU
解码并执行的指令组成 -
数据部分:被
CPU
视作可读写的内存空间
但是我们还可以进一步把上述部分分为更小的单位:段,不同的段会被编译器放置在内存的不同位置
在X86
时有四个段寄存器分别指向不同的段
- cs: 代码段
- ds: 数据段
- ss: 栈段
- es:扩展段
有了这个概念后,就构成了程序的经典内存布局(Memory Layout
)
按照这个经典内存结构,可以看到代码部分只有
- 代码段
.text
一个,存放所有汇编代码 - 数据段
.rodata
存放只读全局数据,如常量,常量字符串等.data
存放可修改全局数据.bss
保存程序中未初始化的全局数据,通常由程序的加载者将这些部分进行零初始化,就是将这块区域逐个字节清零heap
存放程序运行时动态分配的数据,它向高地址增长stack
不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,向低地址增长
3.2 编译流程
从源码得到可执行文件的编译流程有多个阶段
- 编译器 (
Compiler
) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个ASCII
或 其他编码 的文本文件; - 汇编器 (
Assembler
) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File
); - 链接器 (
Linker
) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
显然汇编器会对每一个目标文件都生成一个独立的程序内存布局,它描述的是目标文件内各段所在的位置
但是问题来了,一个工程,可能有成千上万份文件,而且这些文件之间互相关联
链接器的作用就是将所有的目标文件整合成为一个整体内存布局
3.2.1 链接器第一项工作
将来自不同目标文件的段在目标内存布局中重新排布
在链接过程中,我们把每个目标文件的各个段按照段功能进行分类,把功能相同的段被排载一起放在拼接后的新目标文件中
注意到,目标文件 1.o
和 2.o
的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。
3.2.1 链接器第二项工作
将符号替换为具体地址
符号何时被替换为具体地址呢?
因为符号对应的变量或函数都是放在某个段里面的固定位置(如全局变量往往放在 .bss
或者 .data
段中,而函数则放在 .text
段中),所以我们需要等待符号所在的段确定了它们在内存布局中的位置之后才能知道它们确切的地址。当一个模块被转化为目标文件之后,它的内部符号就已经在目标文件中被转化为具体的地址了,因为目标文件给出了模块的内存布局,也就意味着模块内的各个段的位置已经被确定了。
然而,此时模块所用到的外部符号的地址无法确定。我们需要将这些外部符号记录下来,放在目标文件一个名为符号表(Symbol table
)的区域内。由于后续可能还需要重定位,内部符号也同样需要被记录在符号表中。
重定位
外部符号需要等到链接的时候才能被转化为具体地址。假设文件 1 用到了文件 2 所提供的内容,当两个模块的目标文件链接到一起的时候,它们的内存布局会被合并,也就意味着两个模块的各个段的位置均被确定下来。此时,文件 1 用到的来自文件 2 的外部符号可以被转化为具体地址。
注意:两个模块的段在合并后的内存布局中被重新排布,其最终的位置有可能和它们在模块自身的局部内存布局中的位置相比已经发生了变化。因此,每个模块的内部符号的地址也有可能会发生变化,我们也需要进行修正。
Tips:
这里的符号指什么呢?
- 在我们进行模块化编程时,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容
- 要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,这些名字被我们称为符号
- 我们还可以根据其来源于模块内部还是其他模块,可将符号分为内部符号和外部符号
- 机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。
- 调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前 PC 的相对地址
4. 补充解释
局部变量和全局变量
在一个函数的视角中,它能够访问的变量包括以下几种:
- 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前栈指针加上一个偏移量来访问的;
- 全局变量:保存在数据段
.data
和.bss
中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于 gp 加上一个偏移量来访问的。- 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 直接 访问栈上或者全局数据段中的 编译期确定大小 的变量。因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量放在栈帧里面,也可以作为全局变量放在全局数据段中。
真实计算机的加电启动流程
第一阶段:加电后
CPU
的PC
寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory
)的物理地址,随后CPU
开始运行ROM
内的软件。我们一般将该软件称为固件(Firmware
),它的功能是对CPU
进行一些初始化操作,将后续阶段的bootloader
的代码、数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给bootloader
。它大致对应于Qemu
启动的第一阶段,即在物理地址0x1000
处放置的若干条指令。可以看到Qemu
上的固件非常简单,因为它并不需要负责将bootloader
从硬盘加载到物理内存中,这个任务此前已经由Qemu
自身完成了。第二阶段:
bootloader
同样完成一些CPU
的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。可以看到一般情况下bootloader
需要完成一些数据加载工作,这也就是它名字中loader
的来源。它对应于Qemu
启动的第二阶段。在Qemu
中,我们使用的RustSBI
功能较弱,它并没有能力完成加载的工作,内核镜像实际上是和bootloader
一起在Qemu
启动之前加载到物理内存中的。第三阶段:控制权被转移给操作系统。由于篇幅所限后面我们就不再赘述了。
值得一提的是,为了让计算机的启动更加灵活,
bootloader
目前可能非常复杂:它可能也分为多个阶段,并且能管理一些硬件资源,从复杂性上它已接近一个传统意义上的操作系统。
端序或尾序
端序或尾序(
Endianness
),又称字节顺序。在计算机科学领域中,指电脑内存中或在数字通信链路中,多字节组成的字(Word
)的字节(Byte
)的排列顺序。字节的排列方式有两个通用规则。例如,将一个多位数的低位放在较小的地址处,高位放在较大的地址处,则称小端序(little-endian
);反之则称大端序(big-endian
)。常见的x86
、RISC-V
等架构采用的是小端序。
内存地址对齐
内存地址对齐是内存中的数据排列,以及
CPU
访问内存数据的方式,包含了基本数据对齐和结构体数据对齐的两部分。CPU
在内存中读写数据是按字节块进行操作,理论上任意类型的变量访问可以从内存的任何地址开始,但在计算机系统中,CPU
访问内存是通过数据总线(决定了每次读取的数据位数)和地址总线(决定了寻址范围)来进行的,基于计算机的物理组成和性能需求,CPU
一般会要求访问内存数据的首地址的值为 4 或 8 的整数倍。基本类型数据对齐是指数据在内存中的偏移地址必须为一个字的整数倍,这种存储数据的方式,可以提升系统在读取数据时的性能。结构体数据对齐,是指在结构体中的上一个数据域结束和下一个数据域开始的地方填充一些无用的字节,以保证每个数据域(假定是基本类型数据)都能够对齐(即按基本类型数据对齐)。
对于
RISC-V
处理器而言,load/store
指令进行数据访存时,数据在内存中的地址应该对齐。如果访存 32 位数据,内存地址应当按 32 位(4字节)对齐。如果数据的地址没有对齐,执行访存操作将产生异常。这也是在学习内核编程中经常碰到的一种 bug。
5. 参考文档
内核第一条指令(基础篇) - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档 (rcore-os.cn)