【Create my OS】1 最小内核

November 08, 2024

Github代码仓库链接

本章会编写一个最小的、可运行在 QEMU 上的内核,这个内核的功能仅仅是输出一句话

1.1 内核入口点

操作系统的第一行代码

在 CPU 加电后,会首先进行自检,以设置 CPU 的频率、电压等参数,随后跳转到 Bootloader 的入口开始执行,Bootloader 通常进行一些外部设备的探测工作,并初步设置操作系统的运行环境。完成这些操作后,Bootloader 就会将内核代码从磁盘加载到内存中,并将控制转移到内核入口处,开始执行内核。所以 CPU 加电后执行的第一条指令就是 Bootloader 的第一条指令。

1、RISC-V 基金会开源了一款 Bootloader —— OpenSBI,我们并不需要自行实现 Bootloader。

  • OpenSBI 运行在特权级最高的硬件环境中,即 RISC-V CPU 的 Machine Mode(M-Mode),在该特权级下,OpenSBI 可以访问任何硬件信息。
  • 我们所编写的操作系统内核运行在 Supervisor Mode(S-Mode)
  • 而普通的用户程序则运行在 User / Application Mode(U-Mode)
  • OpenSBI 设置内核运行环境所做的最后一件事,就是把 CPU 从 M-Mode 切换到 S-Mode,并跳转到一个固定的地址 0x80200000 处。

2、编写内核入口点,内存布局:

alt text

# entry.S

# 内核的入口点 _start 放置在了 text 段的 entry 标记处

    .section .text.entry    // 指定当前的代码段为 .text.entry (entry为程序入口点)
    .globl _start           // 声明全局符号(使得链接器能够识别 `_start`,并将其作为程序的入口点)
    # 仅仅是设置了 sp 就跳转到 main
_start:
    la sp, bootstacktop     // 将 bootstacktop 地址加载到 sp
    call main               // 跳转到 main 函数
    
# 启动线程的内核栈 bootstack 放置在 bss 段的 stack 标记处
    .section .bss.stack    // 当前段切换到未定义数据段 .bss.stack
    .align 12              // 将 bootstack 对齐到 2^12 字节(即 4KB),数据对齐有助于提高访问效率
    .global bootstack      // 内核栈栈底
bootstack:                
    # 以下 4096 × 16 字节的空间作为 OS 的启动栈
    .space 4096 * 16       // 分配指定大小未初始化空间 byte
    .global bootstacktop   // 内核栈栈顶
bootstacktop:              
  1. 指定程序入口点,设置 sp 指针了内核栈栈顶(下半部分代码在bss段分配的内核栈空间)
  2. 跳转到 main 函数处
// main.c
void main()
{
    while(1) {}
}

1.2 生成内核镜像

1、使用 RISC-V 编译和链接工具:

# 编译
$ riscv64-linux-gnu-gcc -nostdlib -c entry.S -o entry.o
$ riscv64-linux-gnu-gcc -nostdlib -c main.c -o main.o
# 链接
$ riscv64-linux-gnu-ld -o kernel main.o entry.o
  • 使用 gcc 将 .c 或 .S 源文件编译为 .o 目标文件时,需要带上 -nostdlib 参数,即无标准库函数,因为内核的执行环境是 RISC-V 裸机,是没有 C 标准库的。

2、使用 objdump 工具来反汇编,以查看目标文件的信息

$ riscv64-linux-gnu-objdump -x kernel 

kernel:     文件格式 elf64-littleriscv
kernel
体系结构:riscv:rv64, 标志 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x00000000000100f0

程序头:
    LOAD off    0x0000000000000000 vaddr 0x0000000000010000 paddr 0x0000000000010000 align 2**12
         filesz 0x00000000000000fc memsz 0x00000000000000fc flags r-x
    LOAD off    0x0000000000000100 vaddr 0x0000000000011100 paddr 0x0000000000011100 align 2**12
         filesz 0x0000000000000020 memsz 0x0000000000010f00 flags rw-
   STACK off    0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**4
         filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000014  00000000000100e8  00000000000100e8  000000e8  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .got          00000020  0000000000011100  0000000000011100  00000100  2**3
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00010000  0000000000012000  0000000000012000  00000120  2**12
                  ALLOC
  3 .comment      00000029  0000000000000000  0000000000000000  00000120  2**0
                  CONTENTS, READONLY
SYMBOL TABLE:
...
  • 程序的起始地址0x00000000000100f0
  • 程序头是程序被加载进内存时所需要的各个段的信息,其中 vaddr 是该段被加载到的虚拟地址,paddr 是被加载到的物理地址
  • 是程序各个段信息

问题:对于用户进程来说,用户程序的代码和数据都是放在虚拟地址空间的低位置的,所以起始地址在0x00000000000100f0。但是上一节我们看到 OpenSBI 在完成初始化后会跳转到 0x80200000 处,所以得想办法调整程序的内存布局,将入口点放在 0x80200000。 3、链接脚本,通过编写链接脚本来改变程序的内存布局,kernel.ld脚本如下:

/* kernel.ld */

/* 目标架构 */
OUTPUT_ARCH(riscv)

/* 执行入口 */
ENTRY(_start)

/* 数据存放起始地址 */
BASE_ADDRESS = 0x80200000;

SECTIONS
{
    /* . 表示当前地址(location counter) */
    . = BASE_ADDRESS;

    /* start 符号表示全部的开始位置 */
    kernel_start = .;

    text_start = .;

    /* .text 字段 */
    .text : {
        /* 把 entry 函数放在最前面 */
        *(.text.entry)
        /* 要链接的文件的 .text 字段集中放在这里 */
        *(.text .text.*)
    }

    rodata_start = .;

    /* .rodata 字段 */
    .rodata : {
        /* 要链接的文件的 .rodata 字段集中放在这里 */
        *(.rodata .rodata.*)
    }

    data_start = .;

    /* .data 字段 */
    .data : {
        *(.data .data.*)
    }

    bss_start = .;

    /* .bss 字段 */
    .bss : {
        *(.sbss .bss .bss.*)
    }

    /* 内核结束地址 */
    kernel_end = .;
}
  • 链接脚本的整体写在 SECTION{ } 中,里面有多个形如 output section:{ input section list } 的语句,每个都描述了一个整个程序内存布局中的一个输出段是由各个文件中的哪些输入段组成的
  • 这份脚本脚本指定了四个段:text、rodata、data 和 bss。但是我们只需要关注 text 段的情况。text 段被放置在最低处,即 BASE_ADDRESS 的位置,同时,.text .entry 段又被放在了 text 段的最低处,即该段被放到了 0x80200000 处。在 entry.S 中,我们的第一行代码就是放在 .text .entry 段!

4、新的编译和链接方式,生成 ELF 格式目标文件 `kernel。

ELF 文件是一种包含程序和数据的文件格式,可以包含多个段(比如 .text.data.bss 等),并且内含 Program Header,指示操作系统如何将各个段加载到内存中。

$ riscv64-linux-gnu-gcc -nostdlib -c entry.S -o entry.o
$ riscv64-linux-gnu-gcc -nostdlib -c main.c -o main.o
$ riscv64-linux-gnu-ld -T kernel.ld -o kernel main.o entry.o
  • 在链接目标文件时,通过 -T 参数指定链接脚本
  • 再次查看目标文件信息,此时程序已经被放到正确的地址上了
$ riscv64-linux-gnu-objdump -x kernel

kernel:     文件格式 elf64-littleriscv
kernel
体系结构:riscv:rv64, 标志 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x0000000080200000

程序头:
    LOAD off    0x0000000000001000 vaddr 0x0000000080200000 paddr 0x0000000080200000 align 2**12
         filesz 0x0000000000000038 memsz 0x0000000000011000 flags rwx
   STACK off    0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**4
         filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000014  0000000080200000  0000000080200000  00001000  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .got          00000010  0000000080200018  0000000080200018  00001018  2**3
                  CONTENTS, ALLOC, LOAD, DATA
  2 .got.plt      00000010  0000000080200028  0000000080200028  00001028  2**3
                  CONTENTS, ALLOC, LOAD, DATA
  3 .bss          00010000  0000000080201000  0000000080201000  00001038  2**12
                  ALLOC
  4 .comment      00000029  0000000000000000  0000000000000000  00001038  2**0
                  CONTENTS, READONLY
SYMBOL TABLE:
...

5、编译出的 elf 格式目标文件,是可以直接被操作系统加载进内存执行的,具体的过程就是操作系统根据 Program Header 的信息映射各个段到内存中。但是问题是,我们要运行的环境中没有操作系统(因为我们自己就是操作系统),自然没法映射各个段。于是,我们需要自己手动做这个工作。

  • 使用现有的工具生成镜像文件,为了下一步让QEMU虚拟机加载这个镜像运行。
$ riscv64-linux-gnu-objcopy kernel --strip-all -O binary Image
  • --strip-all 表示丢弃符号表信息,这是为了减小镜像文件体积,如果后续需要调试的话可以将其去掉
  • -O binary 表示输出为二进制文件,文件名为 Image

补充(为什么需要镜像文件):

解释:在一个正常的操作系统环境中,程序通过操作系统的内存管理和加载机制进行加载和执行,操作系统会根据 ELF 文件中的 Program Header 来映射各个段到正确的内存位置(如代码段 .text、数据段 .data 等)。由于 没有操作系统,没有人负责自动映射各个段到内存。因此,我们需要将 ELF 文件转化成一个更简单的二进制文件,以便能够直接被加载到内存中。这就是 镜像文件Image)的由来。

  • ELF 文件 是一个 带有头信息和段信息的结构化文件,适用于操作系统加载和内存映射。
  • 镜像文件 是一个 纯粹的二进制文件,它只包含程序的原始二进制数据,不包含 ELF 文件中的元数据(如头部和符号表等)。生成的 Image 文件是可以直接加载到内存中的,QEMU 虚拟机可以将其加载并执行。

1.3 使用 QEMU 运行

1、加载镜像

$ qemu-system-riscv64 \
-machine virt \ 
-bios default \ 
-device loader,file=Image,addr=0x80200000 \ 
--nographic
  • 通过 -bios 指定 Bootloader 为 default 时默认使用为 OpenSBI
  • -device loader 表示将后面的内容直接加载到内存中的某个地址处,并不做其他动作。这里我们加载的文件为 Image,加载到 0x80200000,正好和 Image 内部的地址对上了(需要和Image镜像文件中内核入口地址一致)

输出结果如下:

alt text

2、Makefile 自动化编译

  • 现在仅仅是两个文件的编译就需要四五条语句,到后期文件很多的时候编译工作会十分繁琐,我们可以使用 Makefile 来简化这一过程。
# Makefile

K=kernel

# 后续添加的源文件需要在这里添加,否则不会参与连接
OBJS =                         \
    $K/entry.o              \
    $K/main.o

# 设置交叉编译工具链
TOOLPREFIX := riscv64-linux-gnu-
# $(shell uname) 会执行 uname 命令,返回当前操作系统的名称,如果为Darwin(即macOS)则执行以下语句
ifeq ($(shell uname),Darwin)
    TOOLPREFIX=riscv64-unknown-elf-
endif
CC = $(TOOLPREFIX)gcc
AS = $(TOOLPREFIX)gas
LD = $(TOOLPREFIX)ld
OBJCOPY = $(TOOLPREFIX)objcopy
OBJDUMP = $(TOOLPREFIX)objdump

# QEMU 虚拟机
QEMU = qemu-system-riscv64

# gcc 编译选项
# 开启warning、将警告当成错误处理、O1优化、保留函数调用栈指针、产生GDB所需的调试信息
CFLAGS = -Wall -Werror -O -fno-omit-frame-pointer -ggdb
# 在编译过程中生成依赖文件
CFLAGS += -MD
# 设置代码模型为 medany,要求程序和相关符号都被定义在 2 GB 的地址空间中
CFLAGS += -mcmodel=medany
# 设置环境为Freestanding(不一定以main为入口)、未初始化全局变量放在bss段、链接时不使用标准库、减少获取符号地址所需的指令数
CFLAGS += -ffreestanding -fno-common -nostdlib -mno-relax
CFLAGS += -I.
# 关闭 gcc 的栈溢出保护机制
CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)

# ld 链接选项
LDFLAGS = -z max-page-size=4096

# QEMU 启动选项
# 通过 `-bios` 指定 Bootloader 为 default 时默认使用为 OpenSBI
# -device loader 表示将后面的内容直接加载到内存中的某个地址处,并不做其他动作。这里我们加载的文件为 Image,加载到 0x80200000
QEMUOPTS = -machine virt -bios default -device loader,file=Image,addr=0x80200000 --nographic

all: Image

Image: Kernel

# 链接
Kernel: $(subst .c,.o,$(wildcard $K/*.c)) $(subst .S,.o,$(wildcard $K/*.S))
    $(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/Kernel $(OBJS) # 生成 elf 格式目标文件
    $(OBJCOPY) $K/Kernel -O binary Image  # 生成二进制文件

# compile all .c file to .o file
$K/%.o: $K/%.c    # kernel/目录下的所有.o文件  和 所有.c文件
    $(CC) $(CFLAGS) -c $< -o $@

# compile all .S file to .o file
$K/%.o: $K/%.S    # kernel/目录下的所有.o文件  和 所有.s文件
    $(CC) $(CFLAGS) -c $< -o $@

clean:
    rm -f */*.d */*.o $K/Kernel Image Image.asm

# riscv64-linux-gnu-objdump -x kernel
asm: Kernel
    $(OBJDUMP) -S $K/Kernel > Image.asm

qemu: Image
    $(QEMU) $(QEMUOPTS)


GDBPORT = $(shell expr `id -u` % 5000 + 25000)
QEMUGDB = $(shell if $(QEMU) -help | grep -q '^-gdb'; \
    then echo "-gdb tcp::$(GDBPORT)"; \
    else echo "-s -p $(GDBPORT)"; fi)

qemu-gdb: Image asm
    $(QEMU) $(QEMUOPTS) -S $(QEMUGDB)
  • make Image 命令可以生成内核镜像
  • make clean 可以清理编译的文件
  • make asm 可以生成内核的反汇编文件
  • make qemu 可以直接从 QEMU 加载内核启动

以后添加一个 .c.s 文件都需要在 OBJS 中加入 .o 文件

注意,直接将上述 Makefile 内容复制到文件中时,可能会出现 Makefile:43: *** 缺失分隔符。 停止。 的错误,这时只要将 Makefile 文件中的缩进重新用 tab 键输入,即可解决问题。

1.4 封装 SBI 接口

1、OpenSBI服务:作为一个运行在 M-Mode 下的 Bootloader,OpenSBI 不仅仅需要初始化内核运行环境,还需要为内核提供一些 M-Mode 下的服务。因为我们的内核运行在 S-Mode 下,所以这一层接口被称为 SBI(Supervisor Binary Interface)。运行在 S-Mode 下的内核可以通过 SBI 请求一些 M-Mode 的服务。

  • SBI提供了一些接口,如输出字符接口void sbi_console_putchar(int ch),环境调用号为0x1
  • ecall 指令是环境调用指令,表示从当前权限级向更高一级权限级请求服务。若在 S-Mode 下执行 ecall,就由运行在 M-Mode 下的 OpenSBI 来处理调用请求。在通过 ecall 发起环境调用时,需要指定环境调用号。OpenSBI 实现了 0~8 号调用,其他编号的环境调用将由 OpenSBI 抛给 S-Mode 的内核处理。
  • 一般来说,a7 寄存器存放环境调用号,而 a0a1 和 a2 寄存器用来传递参数(通过寄存器传参的方式进行环境调用最多可以传递三个参数)。环境调用的返回值被存放在 a0 寄存器中。

2、把环境调用的模板抽取成一个宏,定义在 kernel/sbi.h

// kernel/sbi.h

#ifndef SBI_H
#define SBI_H

// SBI 调用号
#define SBI_SET_TIMER               0x0
#define SBI_CONSOLE_PUTCHAR         0x1
#define SBI_CONSOLE_GETCHAR         0x2
#define SBI_CLEAR_IPI               0x3
#define SBI_SEND_IPI                0x4
#define SBI_REMOTE_FENCE_I          0x5
#define SBI_REMOTE_SFENCE_VMA       0x6
#define SBI_REMOTE_SFENCE_VMA_ASID  0x7
#define SBI_SHUTDOWN                0x8

// register声明四个寄存器变量,并通过asm与对应的寄存器绑定,然后赋值
// +表示a0是一个输入输出寄存器
// 输入操作数为a1、a2、a7,使用任意动态分配的寄存器
#define SBI_ECALL(__num, __a0, __a1, __a2)                                    \
    ({                                                                    \
        register unsigned long a0 asm("a0") = (unsigned long)(__a0);  \
        register unsigned long a1 asm("a1") = (unsigned long)(__a1);  \
        register unsigned long a2 asm("a2") = (unsigned long)(__a2);  \
        register unsigned long a7 asm("a7") = (unsigned long)(__num); \
        asm volatile("ecall"                                          \
                 : "+r"(a0)                                       \
                 : "r"(a1), "r"(a2), "r"(a7)                      \
                 : "memory");                                     \
        a0;                                                           \
    })

// 不同参数个数宏拓展,没有参数时传递0
#define SBI_ECALL_0(__num) SBI_ECALL(__num, 0, 0, 0)
#define SBI_ECALL_1(__num, __a0) SBI_ECALL(__num, __a0, 0, 0)
#define SBI_ECALL_2(__num, __a0, __a1) SBI_ECALL(__num, __a0, __a1, 0)

#endif

3、定义不同位宽数据类型的宏

// kernel/types.h

#ifndef TYPES_H
#define TYPES_H

typedef unsigned int    uint;
typedef unsigned short  ushort;
typedef unsigned char   uchar;

typedef unsigned char   uint8;
typedef unsigned short  uint16;
typedef unsigned int    uint32;
typedef unsigned long   uint64;

/* RV64 位宽 */
typedef uint64 usize;

#endif

4、实现简单的SBI函数调用,即sbi.c

// kernel/sbi.c

#include "types.h"
#include "sbi.h"

// 向终端输出一个字符
void
consolePutchar(usize c)
{
    SBI_ECALL_1(SBI_CONSOLE_PUTCHAR, c);
}

// 从控制台读取一个字符
usize
consoleGetchar()
{
    return SBI_ECALL_0(SBI_CONSOLE_GETCHAR);
}

// 关闭系统
void
shutdown()
{
    SBI_ECALL_0(SBI_SHUTDOWN);
    while(1) {}
}

注:此处将各文件函数声明定义到def.h头文件中,方便管理。 5、main()函数调用 consolePutchar() 来输出字符

// kernel/main.c

#include "types.h"
#include "def.h"

void
main()
{
    consolePutchar('a');
    while(1) {}
}
  • spi.o 添加到 Makefile 文件中,运行 make qemu编译输出:
$ make qemu
riscv64-linux-gnu-ld -z max-page-size=4096 -T kernel/kernel.ld -o kernel/Kernel kernel/entry.o kernel/main.o kernel/sbi.o  # 生成 elf 格式目标文件
riscv64-linux-gnu-objcopy kernel/Kernel -O binary Image  # 生成二进制文件
qemu-system-riscv64 -machine virt -bios default -device loader,file=Image,addr=0x80200000 --nographic

OpenSBI v0.7
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name          : QEMU Virt Machine
Platform HART Features : RV64ACDFIMSU
Current Hart           : 0
Firmware Base          : 0x80000000
Firmware Size          : 128 KB
Runtime SBI Version    : 0.2

MIDELEG : 0x0000000000000222
MEDELEG : 0x000000000000b109
PMP0    : 0x0000000080000000-0x000000008001ffff (A)
PMP1    : 0x0000000000000000-0xffffffffffffffff (A,R,W,X)
a
  • 成功输出了 a 字符!!

6、实现 printf

为什么不能直接用 stdio.h 的 printf?

C 语言标准库中的 printf 实现依赖于具体的平台,在底层通过调用具体平台的系统调用来实现功能。我们的内核如今还没有实现系统调用。不太严格地说,C 语言可以分为两个组成部分:非平台相关的语言特性,如 for、while 循环等;和平台相关的部分,如标准库函数,需要依赖操作系统。我们实现操作系统只能借助与平台无关的部分。
  • 此处的 printf() 实现方式主要参考 xv6,并附上逐行详细注释,具体可查看源码
  • 调用 main() 中使用 printf()
void main()
{
    // consolePutchar('a');
    
    printf("Welcome to myOS!\n");
    panic("System exit!!");

    while(1) {}
}
  • 输出如下:
$ make qemu
riscv64-linux-gnu-gcc -Wall -Werror -O -fno-omit-frame-pointer -ggdb -MD -mcmodel=medany -ffreestanding -fno-common -nostdlib -mno-relax -I. -fno-stack-protector -c kernel/printf.c -o kernel/printf.o
riscv64-linux-gnu-gcc -Wall -Werror -O -fno-omit-frame-pointer -ggdb -MD -mcmodel=medany -ffreestanding -fno-common -nostdlib -mno-relax -I. -fno-stack-protector -c kernel/main.c -o kernel/main.o
riscv64-linux-gnu-ld -z max-page-size=4096 -T kernel/kernel.ld -o kernel/Kernel kernel/entry.o kernel/main.o kernel/sbi.o kernel/printf.o  # 生成 elf 格式目标文件
riscv64-linux-gnu-objcopy kernel/Kernel -O binary Image  # 生成二进制文件
qemu-system-riscv64 -machine virt -bios default -device loader,file=Image,addr=0x80200000 --nographic

OpenSBI v0.7
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name          : QEMU Virt Machine
Platform HART Features : RV64ACDFIMSU
Current Hart           : 0
Firmware Base          : 0x80000000
Firmware Size          : 128 KB
Runtime SBI Version    : 0.2

MIDELEG : 0x0000000000000222
MEDELEG : 0x000000000000b109
PMP0    : 0x0000000080000000-0x000000008001ffff (A)
PMP1    : 0x0000000000000000-0xffffffffffffffff (A,R,W,X)
Welcome to myOS!
panic: System exit!!

Profile picture

Written by JokerDebug who works at Southeast University, Nanjing, China You can follow me on Github