rCore-Turorial-Book第三课(计算机启动流程和程序内存布局与编译流程探索)

server/2024/12/22 9:04:06/

本节任务:梳理程序在操作系统中被编译运行的全流程,大体了解我们在没有操作系统的情况下,我们会面对那些困难

重点

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 的物理内存的指定位置中, fileaddr 属性分别可以设置待载入文件的路径以及将文件载入到的 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 总结

为了让内核镜像能够正确对接到 QEMURustSBI上,我们的内核镜像文件必须满足

  • 该文件的开头即为内核待执行的第一条指令
  • 如果不满足上述条件,我们还需要对可执行文件进行一些操作才能得到可提交给 Qemu 的内核镜像

3. 程序内存布局与编译流程

3.1 程序内存布局

当我们将源代码编译成为可执行文件后,我们就看不大懂了,但是我们知道,至少这些字节可以被分为代码和数据两个部分

  • 代码部分:由一条条可以被 CPU 解码并执行的指令组成

  • 数据部分:被 CPU 视作可读写的内存空间

但是我们还可以进一步把上述部分分为更小的单位:不同的段会被编译器放置在内存的不同位置

X86时有四个段寄存器分别指向不同的段

  • cs: 代码段
  • ds: 数据段
  • ss: 栈段
  • es:扩展段

img

​ 有了这个概念后,就构成了程序的经典内存布局Memory Layout

MemoryLayout

按照这个经典内存结构,可以看到代码部分只有

  • 代码段.text一个,存放所有汇编代码
  • 数据段
    • .rodata 存放只读全局数据,如常量,常量字符串
    • .data 存放可修改全局数据
    • .bss 保存程序中未初始化的全局数据,通常由程序的加载者将这些部分进行零初始化,就是将这块区域逐个字节清零
    • heap 存放程序运行时动态分配的数据,它向高地址增长
    • stack 不仅用作函数调用上下文的保存与恢复每个函数作用域内的局部变量也被编译器放在它的栈帧内向低地址增长

3.2 编译流程

从源码得到可执行文件的编译流程有多个阶段

  1. 编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII其他编码 的文本文件;
  2. 汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File);
  3. 链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。

显然汇编器会对每一个目标文件都生成一个独立的程序内存布局,它描述的是目标文件内各段所在的位置

但是问题来了,一个工程,可能有成千上万份文件,而且这些文件之间互相关联

链接器的作用就是将所有的目标文件整合成为一个整体内存布局

3.2.1 链接器第一项工作

将来自不同目标文件的段在目标内存布局中重新排布

在链接过程中,我们把每个目标文件的各个段按照段功能进行分类,把功能相同的段被排载一起放在拼接后的新目标文件中

注意到,目标文件 1.o2.o内存布局是存在冲突的同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。

link-sections

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)。常见的 x86RISC-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)


http://www.ppmy.cn/server/9726.html

相关文章

开发必会:JWT技术揭秘,一次性拿捏

1. 引言 现在前后端分离项目已经成为 主流的开发模式,而在项目开发过程中多多少少都会接触到登录相关的业务,几乎是绕不开的一部分。而只要涉及到登录模块,大部分的开发中都会用提到一种叫做token的东西,顾名思义,tok…

springboot 载入自定义的yml文件转DTO

改进方法,直接spring注入 import cn.hutool.json.JSONUtil; import org.springframework.beans.factory.config.YamlMapFactoryBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import …

基于JavaWeb手工艺品购物系统的设计与实现

1、系统演示视频(演示视频) 2、需要请联系

SEW减速机参数查询 2-2 实践

首先说说结论:在不和SEW官方取得沟通之前,你几乎无法直接通过查阅SEW官方文档得到相关减速机的所有技术参数:比如轴的模数和齿数,轴承的参数。我在周一耗费了一个上午,最终和SEW方面确认后才知晓相关技术参数需要凭借销…

转念是最快提升逆商的方式!

逆商,即逆境商数(Adversity Quotient),是指一个人面对困难和挑战时的应对能力。在充满不确定性的现代社会,逆商的重要性日益凸显。然而,提升逆商并非一蹴而就,它需要时间、实践和策略。在这篇文…

数据融合概念解析:特征融合与特征交互

特征融合与特征交互的区别 我是目录 特征融合与特征交互的区别前言三者关系三者定义特性融合(Feature Fusion):特征拼接(Feature Concatenation):特征交互(Feature Interaction): 特征融合和特征交互关键的不同点数据处理目的应用 总结 前言 遥感系列第14篇。遥感图像处理方向…

Python 将PowerPoint (PPT/PPTX) 转为HTML格式

PPT是传递信息、进行汇报和推广产品的重要工具。然而,有时我们需要将这些精心设计的PPT演示文稿发布到网络上,以便于更广泛的访问和分享。本文将介绍如何使用Python将PowerPoint文档转换为网页友好的HTML格式。包含两个示例: 目录 Python 将…

C语言学习笔记之指针(二)

指针基础知识:C语言学习笔记之指针(一)-CSDN博客 目录 字符指针 代码分析 指针数组 数组指针 函数指针 代码分析(出自《C陷阱和缺陷》) 函数指针数组 指向函数指针数组的指针 回调函数 qsort() 字符指针 一…