总前言

操作系统是大二下学期的一门核心专业课,实验部分分为6个实验(Lab1 ~ Lab6)。采用增量式实验设计思想,每个实验包含的内核代码量在几百行左右,并提供了代码框架和代码示例,要求阅读源码、理解机制并补全核心代码。每个实验可以独立运行和评测,最后实现一个可以在MIPS平台上运行的小型操作系统。以此专题,纪念大二下的每个“备受折磨”的日夜,重温操作系统的核心理念。 实验代码仓库位于OS_on_MIPS.

总流程:实验编写的操作系统代码在Linux系统中,通过Makefile组织,通过交叉编译产生可执行文件;再使用QEMU模拟器运行该可执行文件,实现MOS操作系统的运行。

内核与启动

内核是操作系统最核心的部分,负责与硬件直接交互,并为用户进程提供服务。在计算机启动时,内核需要被加载到内存中,但不宜放在磁盘(CPU无法直接从磁盘访问数据)或内存(易失性)中,因此放在一个非易失性存储器中(如ROM或FLASH)。不过,将操作系统直接放入ROM或FLASH会面临以下几个问题: 1. 存储空间限制: ROM或FLASH的存储空间有限,无法存放较大的内核; 2. 只能启动一个操作系统: 若操作系统内核直接从ROM或FLASH启动,无法实现多重启动。 3. 移植性受限: 将所有硬件相关代码放入内核中,不利于系统的移植。

Bootloader

为了解决这些问题,设计者将硬件初始化工作独立为bootloader程序,并将其保存在ROM或FLASH中,而将操作系统内核保存在磁盘上。Bootloader工作分为两个阶段: 1. Stage 1:硬件加电 -> Stage 1 Bootloader(ROM/FLASH中) * 硬件初始化(如时钟、中断、内存等); * 将Stage 2的代码加载到RAM中,并跳转到Stage 2的入口点

  1. Stage 2:Bootloader(RAM中)
  • 初始化其他硬件设备
  • 加载操作系统内核镜像到RAM
  • 设置启动参数,并将控制权交给操作系统内核
  1. (不属于Bootloader):

BootLoader的操作模式分为:启动加载模式(从本地存储器(如硬盘)加载内核镜像)和 下载模式(通过串口或网络等方式下载远程内核镜像)。

总结:Bootloader 是操作系统启动的第一步,负责硬件初始化和内核加载

QEMU模拟器

在QEMU模拟环境中,由于QEMU模拟器提供bootloader的启动功能(模仿了YAMON的功能);支持直接加载ELF内核的内存,因此上述都不是问题啦。我们的MOS操作系统,从跳转到内核入口开始的。 > 疑问:bootloader如何找到内核入口点呢? > > 解答:Bootloader中的引导加载程序,会解析内核映像的信息,找到内核入口点地址,将其写入某个寄存器(如跳转寄存器);通过执行跳转命令,CPU从该寄存器中读取地址,从而跳转至内核入口。

从零开始搭建MOS

构建内核:从make开始

Makefile为内核地图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
include.mk

target_dir := target #MOS构建目标所在目录
mos_elf :=$(target_dir)/mos #最终需要生成的ELF可执行文件
user_disk := $(target_dir)/fs.img #MOS文件系统使用的磁盘镜像文件
link_script := kernel.lds

modules :=libinit kern #需要生成的子模块
objects :=$(addsuffix /*.o,$(modules)) #要编译出内核所依赖的所有目标文件(*.o)

QEMU_FLAGS :=-cpu4Kc-m64-nographic-Mmalta\
$(shell[-f'$(user_disk)'] &&\
echo'-driveid=ide,file=$(user_disk),if=ide,format=raw') \
-no-reboot #QEMU运行参数

.PHONY:all $(modules) clean

在命令行执行make后,在target目录下生成内核镜像文件mos,步骤如下:

1
2
3
4
5
6
7
8
9
10
targets:=$(mos_elf)

all:$(targets)

$(modules):
$(MAKE)--directory=$@

$(mos_elf): $(modules)
$(LD) $(LDFLAGS)-o$(mos_elf)-N-T$(link_script) $(objects)
# 调用了链接器,将之前构建各模块产生的所有.o文件在linkerscript的指导下,链接到一起,产生最终的mos可执行文件
##### 构建MOS依赖项 1. 执⾏make->构建⽬标all->构建all的依赖项$(targets)->构建$(mos_elf)->构建$(modules)

  1. $(modules) 中的每个⽬录执⾏⼀次 $(MAKE) --directory=$@ .,看⻅形如:make[1]: Entering directory '/home/git/xxxxxxxx/init' 的输出。$(modules)包含的内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    modules :=lib init kern
    targets :=$(mos_elf)

    lab-ge= $(shell [ "$$(echo$(lab)_|cut-f1-d_)" -ge$(1) ] &&echotrue)

    ifeq($(calllab-ge,3),true)
    user_modules +=user/bare
    endif

    ifeq($(calllab-ge,4),true)
    user_modules +=user
    endif

    ifeq($(calllab-ge,5),true)
    user_modules +=fs
    targets +=fs-image
    endif

    objects :=$(addsuffix/*.o, $(modules))$(addsuffix /*.x,$(user_modules))
    modules +=$(user_modules)
    • $(modules) 包含:lib ,init ,kern 这三个构建⽬标,分别对应依赖库、初始化代码和内核代码。
    • $(user_modules)是一个可选的构建目标,它的构建取决于lab变量的值。
    将组合好的$(modules)$(user_modules)的内容对应的生成文件,赋值给$(objects)
构建MOS
  1. $(mos_elf) 构建:执⾏$(LD) -o $(mos_elf) -N -T $(link_script) $(objects)
    • -o --output :设置输出⽂件名
    • -T --script :读取链接脚本
    使⽤$(link_script)$(objects) 链接,输出到$(mos_elf) 位置.
  2. 将组合好的 $(modules)$(user_modules) 的内容对应的⽣成⽂件赋值给$(objects)
    • objects :将⽤户程序和内核程序的⽬标⽂件,分为不同的后缀保存;
    • modules: 设置$(modules) 为所有需要依赖的构建⽬标
  3. $(mos_elf) 下可查看内核⽂件

内核的入口

QEMU模拟器在加载内核时:按照可执行文件中所记录的地址,将内核中的代码、数据,加载到相应的位置,并将CPU控制权移交给内核。那么抛出两个问题: 1. 内核应该被放在哪里呢? 2. 如何将内核加载到上述位置呢?

寻找内核的正确位置:MIPS内存布局

内核放在kseg0.

MIPS 体系结构的虚拟地址空间大小为4GB,布局如下图:

区域 可用性 地址映射 存取方式
kuseg 用户态 唯一可MMU的TLB:虚拟地址 -> 物理地址 通过cache
kseg0 内核态 MMU将虚拟地址最高位清零,得到物理地址(连续映射至物理地址低512MB空间) 通过cache
kseg1 内核态 MMU将虚拟地址高三位清零,得到物理地址(连续映射至物理地址低512MB空间) 不通过cache(使用MIMO访问外设)
kseg2 只能在内核态使用 MMU的TLB:虚拟地址 -> 物理地址 通过cache

控制加载地址:Linker Script

从上一节生成MOS的第3步中,可见:是使⽤$(link_script)$(objects) 链接,生成可执行文件。这里的$(link_script)kernel.lds,其中记录了各个节应该如何映射到段,以及各个段应该被加载到的位置。包括以下段: * .text:包含可执行文件中的代码 * .data:包含需要被初始化的全局变量和静态变量 * .bss:包含未初始化的全局变量和静态变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
OUTPUT_ARCH(mips)   // 架构:MIPS
ENTRY(_start) // 设置MOS内核入口地址为:_start

SECTIONS {
. = 0x80000000;
.tlb_miss_entry : {*(.text.tlb_miss_entry)}

. = 0x80000180;
.exc_gen_entry : {*(.text.exc_gen_entry)}

. = 0x80020000; // .text段的加载地址

.text : { *(.text) }

.data : { *(.data) }

bss_start = .;
.bss : { *(.bss) }
bss_end = .;
. = 0x80400000;
end = . ;
}

MOS内核入口:_start

从上一小节可知:kernel.lds中设置了MOS内核入口ENTRY(_start),对应init/start.S中的_start函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <asm/asm.h>
#include <mmu.h>

.text
EXPORT(_start)
.set at
.set reorder // 启用指令重排:MIPS编译器自动重排指令以优化性能,减少流水线阻塞

/* 清空 .bss 段:清零后跳转至clear_bss_done */
la v0, bss_start
la v1, bss_end
clear_bss_loop:
beq v0, v1, clear_bss_done
sb zero, 0(v0)
addiu v0, v0, 1
j clear_bss_loop

clear_bss_done:
/* 禁用中断 */
mtc0 zero, CP0_STATUS

/* 将sp寄存器设置到:内核栈的起始地址 */
la sp, 0x80400000

/* 跳转到 mips_init:内核初始化的入口点,执行内核的后续初始化工作 */
j mips_init
> 注: > 1. 栈由高地址向低地址方向增长,sp应设置到栈底(即“顶”); > 2. j mips_init采用j而非jal,是因为不存在返回的情况。

内核初始化:mips_init函数

1
2
3
4
5
6
7
8
#ifdef MOS_INIT_OVERRIDDEN
#include <generated/init_override.h>
#else
void mips_init(u_int argc, char **argv, char **penv, u_int ram_low_size) {
printk("init.c:\tmips_init() is called\n");

}
#endif

结尾

经过以上步骤,命令行执行make run,编译运行内核,MOS操作系统可以正常运行起来啦!

下一个Lab,将进入MIPS 4Kc的访存流程与内存映射布局,深入物理内存、虚拟内存的管理办法,以及TLB的清除与重填流程。