2.3 MySBI和BenOS基础实验代码解析

本书大部分实验代码都是基于BenOS实现的。本书的实验会从最简单的裸机程序开始,逐步对其进行扩展和丰富,让其具有进程调度、系统调用等现代操作系统的基本功能。

BenOS基础实验代码包含MySBI和BenOS两部分,其中MySBI是运行在M模式下的固件,为运行在S模式下的操作系统提供引导和统一的接口服务。BenOS基础实验代码的结构如图2.6所示。

图2.6 BenOS基础实验代码的结构

其中,sbi目录包含MySBI的源文件,src目录包含BenOS的源文件,include目录包含BenOS和MySBI共用的头文件。

2.3.1 MySBI基础实验代码解析

本书的实验并没有采用业界流行的OpenSBI固件,而是从零开始编写一个小型可用的SBI固件,以便从底层深入学习RISC-V体系结构。

系统上电后,RISC-V处理器运行在M模式下。通常SBI固件运行在M模式下,为运行在S模式下的操作系统提供引导服务以及SBI服务。不过本小节介绍的MySBI代码仅仅提供引导服务,在后续的实验中会逐步添加SBI服务。

MySBI本质上是一个裸机程序,因此先从链接脚本(Linker Script,LS)开始分析。

任何一种可执行程序(不论是.elf文件还是.exe文件)都是由代码(.text)段、数据(.data)段、未初始化的数据(.bss)段等段(section)组成的。链接脚本最终会把大量编译好的二进制文件(.o文件)综合成二进制可执行文件,也就是把所有二进制文件链接到一个大文件中。这个大文件由总的.text/.data/.bss段描述。下面是MySBI中的一个链接脚本,名为sbi_linker.ld。

<benos/sbi/sbi_linker.ld>
 
1  OUTPUT_ARCH(riscv)
2  ENTRY(_start)
3
4  SECTIONS
5  {
6      INCLUDE "sbi/sbi_base.ld"
7  }

在第1行中,OUTPUT_ARCH说明这个链接脚本对应的处理器体系结构为RISC-V。

在第2行中,指定程序的入口地址为_start。

在第4行中,SECTIONS是链接脚本语法中的关键命令,用来描述输出文件的内存布局。SECTIONS命令告诉链接脚本如何把输入文件的段映射到输出文件的各个段,如何将输入段整合为输出段,以及如何把输出段放入虚拟存储器地址(Virtual Memory Address,VMA)和加载存储器地址(Load Memory Address,LMA)。

在第6行中,通过INCLUDE命令引入sbi/sbi_base.ld脚本。

<benos/sbi/sbi_base.ld>
 
1     /*
2      * 设置SBI的加载入口地址为0x8000 0000
3      */
4
5    . =0x80000000;
6
7    .text.boot : { *(.text.boot) }
8    .text : { *(.text) }
9    .rodata : { *(.rodata) }
10   .data : { *(.data) }
11   . = ALIGN(0x8);
12   bss_begin = .;
13   .bss : { *(.bss*) } 
14   bss_end = .;

在第5行中,“.”非常关键,它代表当前位置计数器(Location Counter,LC),这里把.text段的链接地址设置为0x8000 0000,其中链接地址指的是加载地址(load address)。

在第7行中,输出文件的.text.boot段由所有输入文件(其中的“*”可理解为所有的.0文件,也就是二进制文件)的.text.boot段组成。

在第8行中,输出文件的.text段由所有输入文件的.text段组成。

在第9行中,输出文件的.rodata段由所有输入文件的.rodata段组成。

在第10行中,输出文件的.data段由所有输入文件的.data段组成。

在第11行中,设置对齐方式为按8字节对齐。

在第12~14行中,定义了一个.bss段。

因此,上述链接脚本定义了如下几个段。

.text.boot段:包含系统启动时首先要执行的代码,即把_start函数链接到0x8000 0000地址。

.text段:代码段。

.rodata段:只读数据段。

.data段:数据段。

.bss段:包含未初始化的全局变量和未初始化的局部静态变量。

下面开始编写启动MySBI的汇编代码,并将代码保存为sbi_boot.S文件。

<benos/sbi/sbi_boot.S>
 
1    .section ".text.boot"
2
3    .globl _start
4    _start:
5        /*关闭M模式的所有中断*/
6        csrw mie, zero
7
8        /*设置栈, 栈的大小为4 KB*/
9        la sp, stacks_start
10       li t0, 4096
11       add sp, sp, t0
12
13       /*跳转到C语言的sbi_main()函数*/
14       tail sbi_main
15
16   .section .data
17   .align  12
18   .global stacks_start
19   stacks_start:
20           .skip 4096

启动MySBI的汇编代码不长,下面进行简要分析。

在第1行中,把sbi_boot.S文件编译、链接到.text.boot段。可以在链接脚本sbi_linker.ld中把.text.boot段链接到这个可执行文件的开头,这样程序执行时将从这个段开始。此时,处理器运行在M模式。

在第4行中,_start为程序的入口点。

在第6行中,关闭M模式的所有中断。

在第9~11行中,初始化栈指针,为栈分配4 KB的空间。

在第14行中,跳转到C语言的sbi_main()函数。

sbi_main.c源文件如下。

<benos/sbi/sbi_main.c>
 
1    #include "asm/csr.h"
2
3    #define FW_JUMP_ADDR 0x80200000
4
5    /*
6     * 运行在M模式,并且切换到S模式
7     */
8    void sbi_main(void)
9    {
10       unsigned long val;
11
12       /*设置跳转模式为S模式 */
13       val = read_csr(mstatus);
14       val = INSERT_FIELD(val, MSTATUS_MPP, PRV_S);
15       val = INSERT_FIELD(val, MSTATUS_MPIE, 0);
16       write_csr(mstatus, val);
17
18       /*设置M模式的异常程序计数器,用于mret跳转 */
19       write_csr(mepc, FW_JUMP_ADDR);
20       /*设置S模式的异常向量表入口地址*/
21       write_csr(stvec, FW_JUMP_ADDR);
22       /*关闭S模式的中断*/
23       write_csr(sie, 0);
24       /*关闭S模式的页表转换*/
25       write_csr(satp, 0);
26
27       /*切换到S模式*/
28       asm volatile("mret");
29   }

调用sbi_main()函数的主要目的是把处理器模式从M模式切换到S模式,并跳转到S模式的入口地址处。对于QEMU Virt实验平台来说,S模式的入口地址为0x8020 0000。

在第13~16行中,设置mstatus寄存器中的MPP字段为S模式,并把中断使能保存位MPIE也清除。

在第19行中,当处理器陷入M模式时,mepc寄存器会记录陷入时的异常地址。因此,这里设置M模式的跳转地址为0x8020 0000,执行mret指令会跳转到0x8020 0000地址处。

在第28行中,执行mret指令,完成模式切换。

2.3.2 BenOS基础实验代码解析

本小节介绍BenOS的代码体系结构,目前它只有串口输出功能,类似于裸机程序。BenOS的链接脚本参见benos/src/linker.ld文件。

<benos/src/linker.ld>
 
1    SECTIONS
2    {
3        . =0x80200000,
4
5        .text.boot : { *(.text.boot) }
6        .text : { *(.text) }
7        .rodata : { *(.rodata) }
8        .data : { *(.data) }
9        . = ALIGN(0x8);
10       bss_begin = .;
11       .bss : { *(.bss*) } 
12       bss_end = .;
13   }

上述链接脚本与benos/sbi/sbi_linker.ld文件类似,唯一的区别在于链接地址不一样。BenOS的入口地址为0x8020 0000。

下面开始编写启动BenOS的汇编代码,并将代码保存为boot.S文件。

<benos/src/boot.S>
 
1    .section ".text.boot"
2
3    .globl _start
4    _start:
5        /*关闭中断*/
6        csrw sie, zero
7
8        /*设置栈*/
9        la sp, stacks_start
10       li t0, 4096
11       add sp, sp, t0
12
13       call  kernel_main
14
15   hang:
16       wfi
17       j hang
18
19   .section .data
20   .align  12
21   .global stacks_start
22   stacks_start:
23           .skip 4096
  

启动BenOS的汇编代码不长,下面进行简要分析。

在第1行中,把boot.S文件编译、链接到.text.boot段。可以在链接脚本link.ld中把.text.boot段链接到这个可执行文件的开头,这样程序执行时将从这个段开始。

在第4行中,_start为程序的入口点。此时,处理器模式运行在S模式。

在第6行中,屏蔽所有的中断源。

在第9~11行中,初始化栈指针,为栈分配4 KB的空间。

在第13行中,跳转到C语言的kernel_main()函数。

上述汇编代码还是比较简单的,只做了一件事情——设置栈,跳转到C语言入口。

接下来,编写C语言的kernel_main()函数。本实验的目标是输出一条欢迎语句,因此这个函数的实现比较简单,将代码保存为kernel.c文件。

<benos/src/kernel.c>
 
1    #include "uart.h"
2
3    void kernel_main(void)
4    {
5        uart_init();
6        uart_send_string("Welcome RISC-V!\r\n");
7
8        while (1) {
9            ;
10       }
11   }

上述代码很简单,主要操作是初始化串口和往串口里输出欢迎语句。

接下来,实现简单的串口驱动代码。QEMU使用兼容16550规范的串口控制器。16550串口控制器内部的寄存器如表2.5所示,这些寄存器的偏移地址由芯片的A0~A2引脚确定。另外,预分频寄存器的高/低字节与其他寄存器复用,可以通过线路控制寄存器(LCR)的DLAB字段加以区分。

表2.5 16550串口控制器内部的寄存器

下面是16550串口的初始化代码。

<benos/src/uart.c>
 
1    static unsigned int uart16550_clock = 1843200; //串口时钟
2    #define UART_DEFAULT_BAUD  115200
3 
4    void uart_init(void)
5    {
6        unsigned int divisor = uart16550_clock / (16 * UART_DEFAULT_BAUD);
7 
8        /*关闭中断*/
9        writeb(0, UART_IER);
10 
11       /*打开DLAB字段,以设置波特率分频*/
12       writeb(0x80, UART_LCR);
13       writeb((unsigned char)divisor, UART_DLL);
14       writeb((unsigned char)(divisor >> 8), UART_DLM);
15 
16       /*设置串口数据格式*/
17       writeb(0x3, UART_LCR);
18 
19       /*使能FIFO缓冲区,清空FIFO缓冲区,设置14字节阈值*/
20       writeb(0xc7, UART_FCR);
21   }
 

上述代码关闭中断,设置串口的波特率分频,设置串口数据格式(一个起始位、8个数据位及1个停止位),使能FIFO缓冲区,清空FIFO缓冲区。

接下来,用如下几个函数来发送字符串。

<benos/src/uart.c>
 
1    void uart_send(char c)
2    {
3        while((readb(UART_LSR) & UART_LSR_EMPTY) == 0)
4            ;
5
6        writeb(c, UART_DAT);
7    }
8
9    void uart_send_string(char *str)
10   {
11       int i;
12 
13       for (i = 0; str[i] != '\0'; i++)
14           uart_send((char) str[i]);
15   }

uart_send()函数用于在while循环中判断是否有数据需要发送,这里只需要判断UART_LSR寄存器上的发送移位寄存器即可。

接下来,编写Makefile文件。

<benos/Makefile文件>
 
1    GNU ?= riscv64-linux-gnu
2
3    COPS += -save-temps=obj -g -O0 -Wall -nostdlib -nostdinc -Iinclude -mcmodel=medany
     -mabi=lp64 -march=rv64imafd -fno-PIE -fomit-frame-pointer
4
5    board ?= qemu
6
7    ifeq ($(board), qemu)
8    COPS += -DCONFIG_BOARD_QEMU
9    else ifeq ($(board), nemu)
10   COPS += -DCONFIG_BOARD_NEMU
11   endif
12 
13   ##############
14   #  build benos
15   ##############
16   BUILD_DIR = build_src
17   SRC_DIR = src
18 
19   all : clean benos.bin mysbi.bin benos_payload.bin
20 
21   #检查进程的冗余功能是否开启
22   CMD_PREFIX_DEFAULT := @
23   ifeq ($(V), 1)
24       CMD_PREFIX :=
25   else
26       CMD_PREFIX := $(CMD_PREFIX_DEFAULT)
27   endif
28 
29   clean :
30       rm -rf $(BUILD_DIR) $(SBI_BUILD_DIR) *.bin  *.map *.elf
31 
32   $(BUILD_DIR)/%_c.o: $(SRC_DIR)/%.c
33       $(CMD_PREFIX)mkdir -p $(BUILD_DIR); echo " CC   $@" ; $(GNU)-gcc $(COPS) -c $< -o $@
34 
35   $(BUILD_DIR)/%_s.o: $(SRC_DIR)/%.S
36       $(CMD_PREFIX)mkdir -p $(BUILD_DIR); echo " AS   $@"; $(GNU)-gcc $(COPS) -c $< -o $@
37 
38   C_FILES = $(wildcard $(SRC_DIR)/*.c)
39   ASM_FILES = $(wildcard $(SRC_DIR)/*.S)
40   OBJ_FILES = $(C_FILES:$(SRC_DIR)/%.c=$(BUILD_DIR)/%_c.o)
41   OBJ_FILES += $(ASM_FILES:$(SRC_DIR)/%.S=$(BUILD_DIR)/%_s.o)
42 
43   DEP_FILES = $(OBJ_FILES:%.o=%.d)
44   -include $(DEP_FILES)
45 
46   benos.bin: $(SRC_DIR)/linker.ld $(OBJ_FILES)
47       $(CMD_PREFIX)$(GNU)-ld -T $(SRC_DIR)/linker.ld -o $(BUILD_DIR)/benos.elf  
         $(OBJ_FILES) -Map benos.map; echo " LD $(BUILD_DIR)/benos.elf"
48       $(CMD_PREFIX)$(GNU)-objcopy $(BUILD_DIR)/benos.elf -O binary benos.bin; 
         echo " OBJCOPY benos.bin"
49       $(CMD_PREFIX)cp $(BUILD_DIR)/benos.elf benos.elf
50 
51   ##############
52   #  build SBI
53   ##############
54   # 此处省略,建议读者查看本书配套源代码

上述Makefile文件使用board变量来选择支持NEMU或者QEMU。

GNU用来指定编译器,这里使用riscv64-linux-gnu-gcc。

COPS用来在编译C语言和汇编语言时指定编译选项。

-g:表示编译时加入调试符号表等信息。

-Wall:表示打开所有警告信息。

-nostdlib:表示不连接系统的标准启动文件和标准库文件,只把指定的文件传递给链接器。这个选项常用于编译内核、bootloader等程序,它们不需要标准启动文件和标准库文件。

-nostdinc:表示不包含C语言标准库的头文件。

-mcmodel=medany:目标代码模型,主要表示符号地址的约束。编译器可以利用这些约束生成更有效的代码。RISC-V上主要有两个选项。

medlow:表示程序及符号必须介于绝对地址-2 GB和绝对地址+2 GB之间。

medany:表示程序及符号能访问PC − 2 GB到PC + 2 GB这个地址空间。

-mabi=lp64:表示支持的数据模型。

-march=rv64imafdc:表示处理器的指令集。

-fno-PIE:PIE(Position Independent Executables)表示与位置无关的可执行程序。在GCC中,“-fpic”与“-fPIE”类似,只不过“-fpic”适用于编译动态库,“-fPIE”适用于编译可执行程序。

上述文件会编译和链接两个可执行的ELF文件——benos.elf和mysbi.elf。这些.elf文件包含调试信息,需使用objcopy命令把.elf文件转换为可执行的二进制文件benos.bin和mysbi.bin文件。

2.3.3 合并BenOS和MySBI

NEMU运行环境要求使用一个完整的二进制可执行文件,即需要把benos.bin和mysbi.bin合并。可以利用链接脚本实现这个功能,代码如下。

<benos/src/sbi_linker_payload.ld>
 
1    SECTIONS
2    {
3        INCLUDE "sbi/sbi_base.ld"
4
5        . = 0x80200000;
6        
7        .payload :
8        {
9            PROVIDE(_payload_start = .);
10           *(.payload)
11           . = ALIGN(8);
12           PROVIDE(_payload_end = .);
13       }
14   }

在第3行中,同样使用INCLUDE命令引入sbi/sbi_base.ld脚本。

在第5行中,把当前的链接地址设置为0x8020 0000。

在第7~13行中,新建一个名为.payload的段,这个段的起始地址为0x8020 0000,这个地址是BenOS的入口地址。

在sbi_payload.S汇编文件中使用.incbin伪指令把benos.bin二进制数据嵌入.payload段,完成合并工作。

<benos/sbi/sbi_payload.S>
 
1       .section .payload, "ax"
2       .globl payload_bin
3   payload_bin:
4       .incbin  "benos.bin"

在Makefile文件中还需要使用LD命令进行链接,最后生成benos_payload.elf以及benos_ payload.bin文件。