操作系统本质上也是一个程序,与我们编写的应用程序一样,最终都会转换成二进制的机器指令被CPU执行,只是随着计算机的发展,操作系统这个程序被赋予了更多特殊的职责。今天我们暂且不谈操作系统的诸多功能,而是回归到它作为程序的本身,首先让它的第一行代码在计算机上跑起来。

目标

和入门所有技术一样,最直观的 Quick Start 就是输出一段 "Hello World",所以本章的目标也很简单:在原本没有操作系统的 裸机上运行我们操作系统的第一段代码,此时它只需要完成一件事,就是能输出任意字符。

环境搭建

推荐在 MacOS 或 各 Linux 系统上进行开发。windows环境下理论上也是可行的,下面同样会提供windows的各工具安装地址,但后续的操作步骤主要会以 MacOS/Linux 系统为例展开介绍。

1.RISC-V 工具链

为了让程序能在 RISC-V 的机器上运行,首先要安装 RISC-V 平台的开发工具集,其中包含 riscv64-unknown-elf-gcc (编译器,用于把C源代码编译成可执行文件),riscv64-unknown-elf-gdb (调试器,用于代码debug ) 等等。

与普通的 gcc 编译器不同的是,riscv64-unknown-elf-gcc 是支持交叉编译的,即可以在 X86 的开发机上跨平台编译生成 RISC-V 的可执行二进制文件。

下载地址如下:

下载并打开压缩包,bin 目录下就是所有的工具,将 bin 目录添加到环境变量,方便在任何路径直接使用这些工具 (MacOS 的环境变量配置在 ~/.bash_profile,Linux下为 ~/.bashrc~/.profile)。

配置完成后在命令行输入riscv64-unknown-elf-gcc -v,如果能显示版本信息则表示安装成功。

2.QEMU 模拟器

QEMU 模拟器可以提供一个RISC-V的裸机环境,让我们的操作系统作为第一个程序运行于模拟出来的计算机上。MacOSLinux 环境均可通过源码编译的方式来安装,下载地址为 QEMU下载,推荐选择 4.1 或 5.1 版本。如果下载较慢可以选择使用网盘(链接,提取码 h7ud)。

解压后进入目录,进行编译:

./configure --target-list=riscv64-softmmu     #配置,默认产物路径在/usr/local/bin
make && make install                          #编译并安装

安装完成后在命令行输入 qemu-system-riscv64 -version,能显示版本信息则安装成功。

另外,Windows环境的下载地址为 qemu-win-64,可参考 安装教程

方案分析

本章的目标是输出字符,关于这个问题,第一反应是用C语言的 printf() 直接输出字符串不就行了,但事情并没有那么简单,printf 的底层实现其实调用了操作系统提供的 write() 接口,由OS来完成和硬件设备的数据传输,而现在我们成为操作系统本身了,就目前一无所有的阶段来看,去实现设备的管理、传输等未免太过复杂,只能另辟蹊径了。

首先分析下目前我们手上有什么可用的资源,目前有一个RISC-V架构的CPU,上面有一系列可用的寄存器 (CPU内部用来存储状态的小型存储器),还有一块内存,同时有一些可用的RISC-V指令,可以指挥CPU把寄存器的数据写到内存,以及把内存的数据读到寄存器。

另外还有一个简单而强大的通信接口不容忽视:串口,它可以把数据一位一位地按顺序传输,是常用的调试工具。在qemu上会把串口的数据输出到控制台中,在开发板上则可以通过串口线连接开发机,输出到开发机的终端上,所以只要往串口中写入字符,不仅是模拟机还是真机都可以完成显示。同时串口会被映射到一个内存地址,只要往这个内存地址里写数据,就会自动通过串口进行发送。所以现在问题就变得简单了:如何利用已有的RISC-V汇编指令向一个指定的地址写入数据。

RISC-V虚拟计算机选取的是qemu提供的实现之一:virt,通过 源码 中的 virt_memmap 数组可以找到对硬件设备的地址映射,其中我们关心的部分如下:

  • ROM (Read-Only Memory):只读存储器,这里放着计算机中固化的少量代码,主要负责引导工作,计算机加电后会首先被执行,对应的初始地址是 0x1000 (16进制)
  • UART0:通用异步收发传输器,也就是上面说的串口,起始地址是 0x10000000
  • RAM(Ramdom Access Memory):随机存取存储器,这是真正的主存,程序运行时的指令和数据都存放于此,地址从 0x80000000 开始

所以最终方案也明确了,就是通过向 0x10000000 内存地址写入字符来完成 串口 输出。

代码实现

在开始编码之前,先介绍下程序编译的过程:

本次我们从汇编文件开始编写,会通过汇编和链接两个过程生成最终可执行的二进制文件。

汇编

首先创建一个汇编文件 entry.S,代表这是内核的入口,核心代码如下:

.section .text
li t1, 0x10000000        # 将串口起始地址写入寄存器t1
li t0, 'H'               # 将字符'H'写入寄存器t0
sb t0, 0(t1)             # 向t1中的地址(串口起始地址) 写入t0中的值(字符'H')

# 重复上述操作输出剩余字符
li t0, 'e' 
sb t0, 0(t1)
... ...

第1行是通用的汇编写法,由于最终生成的可执行文件是划分成不同段的,所以使用了 ".section" 命令来声明了一个名为 ".text" 的段,表示以下的汇编代码都会被放到可执行文件的 .text 段中。至于这里段的名字是否必须叫 ".text",后面再详细说明。

接下来的3行就是RISC-V的汇编指令了,使用到了以下两条指令:

  • li (Load Immediate):用法是 li reg, imm,表示将一个数加载到寄存器中
  • sb (Store Byte):用法是 sb reg1, offset(reg2),表示将寄存器1的值存入地址为 寄存器2的值加上offset 的内存中

所以第2行将串口在内存中的映射地址 0x10000000 放到寄存器t0中,第3行将字符'H'放到放到寄存器t1中,第4行则向 地址为寄存器t0中值 (0x10000000) 的内存写入寄存器t1中的值('H'),写入后这个字符会通过串口进行发送。既然完成了单个字符的输出,多个字符自然不在话下,只要重复以上两行,不断将字符加载到寄存器,再写到串口地址,就能实现字符串的输出。

RISC-V有 32 个可供用户程序使用的寄存器,以及1个 PC寄存器 (保存当前需要执行的指令地址),寄存器的说明可以参考文档,本次使用到的只有 t0 和 t1 这两个,都是临时寄存器 (Temporary registers),没有什么特殊含义。另外推荐使用这个模拟器来学习更多RISC-V指令,可以在运行指令时可视化看到寄存器和内存的变化。

完成代码编写后,将汇编文件编译成目标文件:

riscv64-unknown-elf-gcc -c entry.S -o kernel.o

其中,-c 表示仅做汇编动作,将后面跟着的 entry.S 汇编成目标文件,-o 指定目标文件的路径名称,执行完将在当前路径生成 kernel.o 目标文件 (目标文件中已经是二进制文件了,里面包含着机器指令码,但只是中间产物,并不能直接运行)。

链接

链接的作用是根据 目标文件 生成 ELF文件 (Executable and Linkable Format),也就是可执行文件,链接器在这个过程中做了两件很关键的事,一是将ELF文件分成不同段 (section, 其实就是part的意思),目标文件会根据段的定义被整合在一起;二是确定了内存分布 (Memory Layout),也就是当这个ELF文件被加载到内存中执行的时候,各个段会放置在内存的什么地址。

当我们直接进行链接时,段的定义以及在内存的分布都不受我们控制,会使用默认的配置来完成,好在可以通过指定链接脚本来自定义配置。新建一个链接脚本文件 kernerl.ld

SECTIONS
{
  . = 0x80000000;
  .text : {
    *(.text)
  }
}
  • 第1行:SECTIONS表示以下是对段的定义
  • 第3行:声明了内存映射的地址,表示下面的段会被加载到内存的 0x80000000 地址 (至于为什么是这个值,下一节再详细说明)
  • 第4行:定义了一个ELF文件中名为 ".text" 的段,这个段就是存放代码指令的地方,因为我们本次的程序不涉及全局数据,所以不需要数据段,只定义一个.text代码段即可
  • 第5行:*号为通配符,代表会将所有目标文件中的 .text 段放入可执行文件的 .text段中。这一行的 .text 对应的就是上一节在汇编文件开头定义的段,这表示我们编写的汇编指令未来会被加载到0x80000000地址。同时这个段的名称是可以修改的,只要二者保持一致即可。

接着执行链接操作,注意使用 -T 参数指定我们自定义的链接脚本,同时用 -o 指定输出的可执行文件,执行完毕后将得到可执行文件 kernel:

riscv64-unknown-elf-ld kernel.o -T kernel.ld -o kernel

如果想详细了解下链接以及ld脚本的用法,可以参考这篇文章

运行

已经成功产出了可执行文件,接下来就要把它交给qemu模拟出来的计算机运行,在此之前先了解下qemu的启动流程:qemu在启动后执行的第一行指令的地址在 0x1000,这就是前面提到的 ROM 的地址,ROM上的代码是由QEMU自己提供的,在执行其内的数条指令后,就会跳转到 RAM 物理内存的起始地址继续执行指令,这一地址的值正是 0x80000000,从这一刻开始CPU的执行权就交给了我们编写的内核,这也是为什么上一节在链接脚本内需要将代码段的起 始地址设置成 0x80000000。

执行qemu命令启动虚拟计算机,并启动内核程序:

qemu-system-riscv64 -machine virt -smp 1 -m 128M -bios none -nographic -kernel kernel
  • -machine virt 指定了虚拟计算机的实现为 virt,使用-smp 1 指定了机器cpu的个数为1,-m 128M 则是设置内存为128M
  • -bios none 表示不需要bios或bootloader等引导程序来加载kernel,这里我们简单处理,启动后直接执行操作系统的代码;-nographic 表示不需要图形界面,此时串口的输出会显示在终端
  • -kernel kernel 将编译和链接成的内核可执行文件传给qemu,qemu会根据文件中的内存映射信息将kernel加载到指定地址

执行后就可以在终端上看到输出的字符串了,这也代表着操作系统的第一条指令成功运行了!

minos-first-print

注: 按住 ctrl+A+X 可以退出qemu的运行 (需要先按住ctrl+A,松开后再按一下X)。

GDB调试

接下来使用调试工具GDB对机器的整个启动流程进行单步追踪,可以帮助我们更清楚地看到每一条指令在什么内存地址、以什么顺序执行、寄存器和内存的变化如何。

(1) 在终端1运行qemu,并添加 -s -S参数:

qemu-system-riscv64 -machine virt -smp 1 -m 128M -bios none -nographic -kernel kernel -s -S

其中 -s 表示 qemu将监听本地TCP端口 (默认为1234) 供GDB客户端连接,-S 则表示qemu将阻塞等待GDB客户端的命令。

(2) 在终端2运行GDB工具:

riscv64-unknown-elf-gdb -ex 'target remote localhost:1234'

(3) 单步调试

首先在GDB命令行中输入 x/10i $pc 查看当前内存中即将被执行的10条指令。

其中x的全称是examine,用于查看内存数据,10i表示10条指令(instruction),$pc表示要查看的起始地址为PC寄存器中的值 (正是CPU下一条要执行的指令地址) 。

(gdb) x/10i $pc
=> 0x1000:    auipc    t0,0x0
   0x1004:    addi    a1,t0,32
   0x1008:    csrr    a0,mhartid
   0x100c:    0x182b283
   0x1010:    jr    t0
   ... ... 

可以看出机器启动后的第一条指令的地址是 0x1000,和前面猜想的一致,对应ROM的地址,接下来几条指令都是机器固件中硬编码的。

接着不断输入 si (step instruction) 命令,单步执行指令:

(gdb) si
0x0000000000001004 in ?? ()
(gdb) si
0x0000000000001008 in ?? ()
... ... 

每次执行后可以继续用 x/10i $pc 查看指令执行情况。直到位于 0x1010 的指令 jr t0 被执行后,可以看到地址跳转到了 0x80000000,这对应着主存RAM的起始地址:

(gdb) x/10i $pc
=> 0x80000000:    lui    t1,0x10000
   0x80000004:    li    t0,72
   0x80000008:    sb    t0,0(t1)
   ... ...

和预想的相同,0x80000000处就到了我们自己编写的指令了,其中第一行的lui是汇编器对li伪指令的翻译,第二行的数字72正是字符'H'对应的ASCII码。

继续单步执行以上三行后,可以使用 info reg 查看全部寄存器的内容,也可以查看单个寄存器的内容,此时t0存储的是字符'H',t1存储的是串口的地址 0x10000000:

(gdb) p/x $t0
0x48
(gdb) p/x $t1
0x10000000

Makefile

前面的编译、链接、运行、调试都是通过手动输入完整命令来进行,操作比较复杂且重复,可以通过 make 工具来封装这些操作,简化构建流程。

首先调整下目录结构,创建 kernel目录,用于放置操作系统的内核代码,将前面编写的汇编指令和链接脚本放到该目录下。同时在项目根目录下创建 Makefile 文件 (构建脚本),这是make的核心,用于存放构建规则:

minos
  ├── Makefile
  └── kernel
       ├── entry.S
       └── kernel.ld

Makefile由一系列规则组成,每条规则的格式很简单,如下:

<target> : <prerequisites> 
    <commands>

本质是用目标 (target) 来代替命令 (commands),类似于起了别名,同时声明了这个目标依赖的其他目标 (prerequisites),从而实现对命令执行的编排。在执行 make <target> 时,首先会找到其依赖的目标,并执行对应命令,然后再执行自身命令。 另外需要注意:commands前面的缩进是tab而不是空格。

所以上面的构建流程在 Makefile 中的表示如下图(源码见 Github):

在Makefile所在目录下执行 make qemu 就可以完成编译、链接,并通过qemu启动kernel;执行make qemu-gdb 可以进入调试状态;执行 make clean 则清除编译生产的产物 (是不是和Maven的各生命周期很相似)。

到这一步,就完成了项目结构的初始化,并且简化了构建的流程,为后续的开发打好了基础(项目地址:https://github.com/open-program/minos)。

开发板运行 (可选)

完成了在qemu模拟器中的运行,接着尝试在开发板中运行,来验证上述kernel程序是可以支持真机的。下面以 全志D1 RISC-V开发板 为例进行介绍,这里主要分享大致思路,如果对具体细节感兴趣的话可以在下方评论。

整体方案不变,还是通过写入指定地址来实现串口输出,不同的是,在模拟器环境中是由qemu将kernel程序加载到指定内存,但到了真机下这一过程就没那么简单了,需要了解是由谁来完成kernel的加载、从磁盘什么位置加载、以及加载到内存什么地址。

回答这几个问题之前,需要了解下开发板的启动流程,在全志D1开发板中,完整的启动流程分为几步:

  1. 启动后运行 ROM 中的固化的指令(这一步和qemu上一致)
  2. SPL (Second Program Loader,二级程序加载器,又叫boot0) 从flash闪存 (类似于硬盘) 的起始位置加载到 SRAM (静态随机内存) 中运行
  3. SPL 将 OpenSBI (Open Supervisor Binary Interafce,操作系统二进制接口) 和 u-boot (一种boot loader) 加载到 DRAM (动态随机内存) 中运行
  4. uboot会 将操作系统 kernel 从flash的boot分区 加载到DRAM中运行,启动流程完成

根据这个启动流程,可以考虑从哪一步切入,将我们的kernel程序成功加载和运行。有以下方案可选:

  • 方案1 (不可行):简化启动流程,由 ROM -> Kernel,实验发现无法正常启动,原因是启动过程中SPL负责了时钟、内存、串口等硬件的初始化,想要省去这一步就必须在kernel程序中实现初始化工作
  • 方案2(不可行):简化启动流程,由 ROM -> SPL -> Kernel,但该开发板的SPL会对 OpenSBI 和 uboot 进行强校验,所以能正常启动但无法成功引导自定义的 kernel 程序
  • 方案3 (可行):通过工具将 kernel 程序直接加载到内存运行,在全志D1开发板中可以通过xfel 工具在 FEL模式下 初始化内存、将kernel加载到内存并运行 (xfel -> Kernel)。 但是这种方式更像是在运行一个程序,而没有裸机加电直接运行操作系统的感觉
  • 方案4 (可行):遵循标准的启动流程 (ROM -> SPL -> OpenSBI -> uboot -> Kernel),而将开发板自带的操作系统替换成由我们开发的,从而实现kernel在完成的启动流程中运行

下面介绍下 方案4 的实现,主要步骤如下:

1.标准启动镜像编译: 先参考文章修改uboot的引导指令配置,将原本用于引导linux系统的复杂指令改成简单的地址跳转,方便启动我们自制的内核;再根据D1芯片的官方文档 编译启动镜像 (包含SPL、OpenSBI、u-boot、自带操作系统Tina)

2.kernel代码改动: 这一步是针对真机环境对我们编写的代码进行调整,从手册中获得该芯片的串口地址为 0x02500000,所以需对entry.S中的串口地址进行修改、

但需要注意的是,前面编译生成的 kernel 二进制文件是ELF文件,模拟环境时qemu会解析ELF文件的头信息 (如在链接脚本中定义的地址 0x80000000)并运行,但在真机环境是无法识别这些信息的,头部的冗余数据反而会影响第一条指令的运行,所以要丢弃掉这部分元数据生成一个干净的二进制文件 kernel.bin,命令如下:

riscv64-unknown-elf-objcopy -O binary kernel/kernel kernel/kernel.bin

移除掉元数据后另外一个问题出现了,如何确定将kernel加载到内存的具体地址呢?答案是会由uboot指定,在D1-H开发板的启动中,uboot会将kernel加载到内存的 0x45000000 (DRAM中),所以链接脚本 kernel.ld 中的地址声明是无需修改的。

3.镜像替换和打包: 将标准启动镜像中的 boot.img 替换成我们制作的 kernel.bin,执行 pack 命令打包,就会生成包含自制kernel的标准启动包。

4.烧写和运行: 将产物包烧写到开发板的 flash闪存中(linux下用LiveSuit,windows下用PhoenixSuit,参考文档),给开发板上电,并通过串口将开发板和开发机相连,在串口终端看到机器启动后kernel成功运行。

开发板运行
注:左图中右上角的三色线就是用于连接开发板的串口,左下角的type-c接口是电源接口。右图中最后一行日志是由我们自制的kernel打印的,其余日志来源于标准启动流程中的其它引导程序。

总结

本节完成了从零开发操作系统的第一步,在 模拟器环境 和 真机环境 实现了内核的第一条指令的运行,在不依赖任何库的情况下通过串口完成了字符的输出,同时了解了 RISC-V下的基本指令、寄存器、编译链接、QEMU、串口、GDB、Make等的使用,已经搭建出了一套操作系统的开发环境。

目前的 MinOS kernel 与其说是操作系统,不如说只是一个简单的裸机程序,因为它只实现了自身的运行而没有完成任何操作系统的功能,所以在下一节将开始讨论操作系统的职责并逐步给内核程序添加功能。

标签: minos

已有 2 条评论

  1. 我是谁 我是谁

    你好呀

  2. 强的!!

添加新评论