网站后台工程师,东莞网站建设培训,微信引流推广网站建设,企业网站 备案 网站名称Github代码仓库链接 本章会编写一个最小的、可运行在 QEMU 上的内核#xff0c;这个内核的功能仅仅是输出一句话 1.1 内核入口点 操作系统的第一行代码 在 CPU 加电后#xff0c;会首先进行自检#xff0c;以设置 CPU 的频率、电压等参数#xff0c;随后跳转到 Bootloader …Github代码仓库链接 本章会编写一个最小的、可运行在 QEMU 上的内核这个内核的功能仅仅是输出一句话 1.1 内核入口点 操作系统的第一行代码 在 CPU 加电后会首先进行自检以设置 CPU 的频率、电压等参数随后跳转到 Bootloader 的入口开始执行Bootloader 通常进行一些外部设备的探测工作并初步设置操作系统的运行环境。完成这些操作后Bootloader 就会将内核代码从磁盘加载到内存中并将控制转移到内核入口处开始执行内核。所以 CPU 加电后执行的第一条指令就是 Bootloader 的第一条指令。 1、RISC-V 基金会开源了一款 Bootloader —— OpenSBI我们并不需要自行实现 Bootloader。
OpenSBI 运行在特权级最高的硬件环境中即 RISC-V CPU 的 Machine ModeM-Mode在该特权级下OpenSBI 可以访问任何硬件信息。我们所编写的操作系统内核运行在 Supervisor ModeS-Mode而普通的用户程序则运行在 User / Application ModeU-ModeOpenSBI 设置内核运行环境所做的最后一件事就是把 CPU 从 M-Mode 切换到 S-Mode并跳转到一个固定的地址 0x80200000 处。 2、编写内核入口点内存布局 # entry.S# 内核的入口点 _start 放置在了 text 段的 entry 标记处.section .text.entry // 指定当前的代码段为 .text.entry (entry为程序入口点).globl _start // 声明全局符号使得链接器能够识别 _start并将其作为程序的入口点# 仅仅是设置了 sp 就跳转到 main
_start:la sp, bootstacktop // 将 bootstacktop 地址加载到 spcall 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: 指定程序入口点设置 sp 指针了内核栈栈顶下半部分代码在bss段分配的内核栈空间跳转到 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**12filesz 0x00000000000000fc memsz 0x00000000000000fc flags r-xLOAD off 0x0000000000000100 vaddr 0x0000000000011100 paddr 0x0000000000011100 align 2**12filesz 0x0000000000000020 memsz 0x0000000000010f00 flags rw-STACK off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**4filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-节
Idx Name Size VMA LMA File off Algn0 .text 00000014 00000000000100e8 00000000000100e8 000000e8 2**1CONTENTS, ALLOC, LOAD, READONLY, CODE1 .got 00000020 0000000000011100 0000000000011100 00000100 2**3CONTENTS, ALLOC, LOAD, DATA2 .bss 00010000 0000000000012000 0000000000012000 00000120 2**12ALLOC3 .comment 00000029 0000000000000000 0000000000000000 00000120 2**0CONTENTS, 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 kernelkernel 文件格式 elf64-littleriscv
kernel
体系结构riscv:rv64 标志 0x00000112
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x0000000080200000程序头LOAD off 0x0000000000001000 vaddr 0x0000000080200000 paddr 0x0000000080200000 align 2**12filesz 0x0000000000000038 memsz 0x0000000000011000 flags rwxSTACK off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**4filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-节
Idx Name Size VMA LMA File off Algn0 .text 00000014 0000000080200000 0000000080200000 00001000 2**1CONTENTS, ALLOC, LOAD, READONLY, CODE1 .got 00000010 0000000080200018 0000000080200018 00001018 2**3CONTENTS, ALLOC, LOAD, DATA2 .got.plt 00000010 0000000080200028 0000000080200028 00001028 2**3CONTENTS, ALLOC, LOAD, DATA3 .bss 00010000 0000000080201000 0000000080201000 00001038 2**12ALLOC4 .comment 00000029 0000000000000000 0000000000000000 00001038 2**0CONTENTS, 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,fileImage,addr0x80200000 \
--nographic
通过 -bios 指定 Bootloader 为 default 时默认使用为 OpenSBI-device loader 表示将后面的内容直接加载到内存中的某个地址处并不做其他动作。这里我们加载的文件为 Image加载到 0x80200000正好和 Image 内部的地址对上了需要和Image镜像文件中内核入口地址一致 输出结果如下 2、Makefile 自动化编译
现在仅仅是两个文件的编译就需要四五条语句到后期文件很多的时候编译工作会十分繁琐我们可以使用 Makefile 来简化这一过程。
# MakefileKkernel# 后续添加的源文件需要在这里添加否则不会参与连接
OBJS \$K/entry.o \$K/main.o# 设置交叉编译工具链
TOOLPREFIX : riscv64-linux-gnu-
# $(shell uname) 会执行 uname 命令返回当前操作系统的名称如果为Darwin(即macOS)则执行以下语句
ifeq ($(shell uname),Darwin)TOOLPREFIXriscv64-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 -mcmodelmedany
# 设置环境为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 21 echo -fno-stack-protector)# ld 链接选项
LDFLAGS -z max-page-size4096# QEMU 启动选项
# 通过 -bios 指定 Bootloader 为 default 时默认使用为 OpenSBI
# -device loader 表示将后面的内容直接加载到内存中的某个地址处并不做其他动作。这里我们加载的文件为 Image加载到 0x80200000
QEMUOPTS -machine virt -bios default -device loader,fileImage,addr0x80200000 --nographicall: ImageImage: 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.asmqemu: 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 下的 BootloaderOpenSBI 不仅仅需要初始化内核运行环境还需要为内核提供一些 M-Mode 下的服务。因为我们的内核运行在 S-Mode 下所以这一层接口被称为 SBISupervisor Binary Interface。运行在 S-Mode 下的内核可以通过 SBI 请求一些 M-Mode 的服务。
SBI提供了一些接口如输出字符接口void sbi_console_putchar(int ch)环境调用号为0x1ecall 指令是环境调用指令表示从当前权限级向更高一级权限级请求服务。若在 S-Mode 下执行 ecall就由运行在 M-Mode 下的 OpenSBI 来处理调用请求。在通过 ecall 发起环境调用时需要指定环境调用号。OpenSBI 实现了 08 号调用其他编号的环境调用将由 OpenSBI 抛给 S-Mode 的内核处理。一般来说a7 寄存器存放环境调用号而 a0、a1 和 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)#endif3、定义不同位宽数据类型的宏
// kernel/types.h#ifndef TYPES_H
#define TYPES_Htypedef 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;#endif4、实现简单的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.hvoid
main()
{consolePutchar(a);while(1) {}
}将 spi.o 添加到 Makefile 文件中运行 make qemu编译输出
$ make qemu
riscv64-linux-gnu-ld -z max-page-size4096 -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,fileImage,addr0x80200000 --nographicOpenSBI 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.2MIDELEG : 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 -mcmodelmedany -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 -mcmodelmedany -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-size4096 -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,fileImage,addr0x80200000 --nographicOpenSBI 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.2MIDELEG : 0x0000000000000222
MEDELEG : 0x000000000000b109
PMP0 : 0x0000000080000000-0x000000008001ffff (A)
PMP1 : 0x0000000000000000-0xffffffffffffffff (A,R,W,X)
Welcome to myOS!
panic: System exit!!