从SBI到stdio

OpenSBI作为运行在M态的软件(或者说固件), 提供了一些接口供我们编写内核的时候使用。

我们可以通过ecall指令(environment call)调用OpenSBI。通过寄存器传递给OpenSBI一个”调用编号“,如果编号在 0-8 之间,则由OpenSBI进行处理,否则交由我们自己的中断处理程序处理(暂未实现)。有时OpenSBI调用需要像函数调用一样传递参数,这里传递参数的方式也和函数调用一样,按照riscv的函数调用约定(calling convention)把参数放到寄存器里。可以阅读SBI的详细文档

知识点

ecall(environment call),当我们在 S 态执行这条指令时,会触发一个 ecall-from-s-mode-exception,从而进入 M 模式中的中断处理流程(如设置定时器等);当我们在 U 态执行这条指令时,会触发一个 ecall-from-u-mode-exception,从而进入 S 模式中的中断处理流程(常用来进行系统调用)。

关于这个,大三的时候会被好好折磨的噢【坏笑】。

C语言并不能直接调用ecall, 需要通过内联汇编来实现。

// libs/sbi.c
#include <sbi.h>
#include <defs.h>

//SBI编号和函数的对应
uint64_t SBI_SET_TIMER = 0;
uint64_t SBI_CONSOLE_PUTCHAR = 1;
uint64_t SBI_CONSOLE_GETCHAR = 2;
uint64_t SBI_CLEAR_IPI = 3;
uint64_t SBI_SEND_IPI = 4;
uint64_t SBI_REMOTE_FENCE_I = 5;
uint64_t SBI_REMOTE_SFENCE_VMA = 6;
uint64_t SBI_REMOTE_SFENCE_VMA_ASID = 7;
uint64_t SBI_SHUTDOWN = 8;
//sbi_call函数是我们关注的核心
uint64_t sbi_call(uint64_t sbi_type, uint64_t arg0, uint64_t arg1, uint64_t arg2) {
    uint64_t ret_val;
    __asm__ volatile (
        "mv x17, %[sbi_type]\n"
        "mv x10, %[arg0]\n"
        "mv x11, %[arg1]\n"
        "mv x12, %[arg2]\n"   //mv操作把参数的数值放到寄存器里
        "ecall\n"    //参数放好之后,通过ecall, 交给OpenSBI来执行
        "mv %[ret_val], x10"  
        //OpenSBI按照riscv的calling convention,把返回值放到x10寄存器里
        //我们还需要自己通过内联汇编把返回值拿到我们的变量里
        : [ret_val] "=r" (ret_val)
        : [sbi_type] "r" (sbi_type), [arg0] "r" (arg0), [arg1] "r" (arg1), [arg2] "r" (arg2)
        : "memory"
    );
    return ret_val;
}

void sbi_console_putchar(unsigned char ch) {
    sbi_call(SBI_CONSOLE_PUTCHAR, ch, 0, 0); //注意这里ch隐式类型转换为int64_t
}

void sbi_set_timer(unsigned long long stime_value) {
    sbi_call(SBI_SET_TIMER, stime_value, 0, 0);
}

知识点

函数调用与calling convention

我们知道,编译器将高级语言源代码翻译成汇编代码。对于汇编语言而言,在最简单的编程模型中,所能够利用的只有指令集中提供的指令、各通用寄存器、 CPU 的状态、内存资源。那么,在高级语言中,我们进行一次函数调用,编译器要做哪些工作利用汇编语言来实现这一功能呢?

显然并不是仅用一条指令跳转到被调用函数开头地址就行了。我们还需要考虑:

  • 如何传递参数?

  • 如何传递返回值?

  • 如何保证函数返回后能从我们期望的位置继续执行?

等更多事项。通常编译器按照某种规范去翻译所有的函数调用,这种规范被称为 calling convention 。值得一提的是,为了实现函数调用,我们需要预先分配一块内存作为 调用栈 ,后面会看到调用栈在函数调用过程中极其重要。你也可以理解为什么第一章刚开始我们就要分配栈了。

可以参考riscv calling convention

现在可以输出一个字符了,有了第一个,就会有第二个第三个……第无数个。

这样我们就可以通过sbi_console_putchar()来输出一个字符。接下来我们要做的事情就像月饼包装,把它封了一层又一层。

console.c只是简单地封装一下

// kern/driver/console.c#include <sbi.h>#include <console.h>
void cons_putc(int c) { sbi_console_putchar((unsigned char)c); }

stdio.c里面实现了一些函数,注意我们已经实现了ucore版本的puts函数: cputs()

// kern/libs/stdio.c
#include <console.h>
#include <defs.h>
#include <stdio.h>

/* HIGH level console I/O */

/* *
 * cputch - writes a single character @c to stdout, and it will
 * increace the value of counter pointed by @cnt.
 * */
static void cputch(int c, int *cnt) {
    cons_putc(c);
    (*cnt)++;
}
/* cputchar - writes a single character to stdout */
void cputchar(int c) { cons_putc(c); }

int cputs(const char *str) {
    int cnt = 0;
    char c;
    while ((c = *str++) != '\0') {
        cputch(c, &cnt);
    }
    cputch('\n', &cnt);
    return cnt;
}

我们还在libs/printfmt.c实现了一些复杂的格式化输入输出函数。最后得到的cprintf()函数仍在kern/libs/stdio.c定义,功能和C标准库的printf()基本相同。

可能你注意到我们用到一个头文件defs.h, 我们在里面定义了一些有用的宏和类型

// libs/defs.h
#ifndef __LIBS_DEFS_H__
#define __LIBS_DEFS_H__
...
/* Represents true-or-false values */
typedef int bool;
/* Explicitly-sized versions of integer types */
typedef char int8_t;
typedef unsigned char uint8_t;
typedef short int16_t;
typedef unsigned short uint16_t;
typedef int int32_t;
typedef unsigned int uint32_t;
typedef long long int64_t;
typedef unsigned long long uint64_t;
...
/* *
 * Rounding operations (efficient when n is a power of 2)
 * Round down to the nearest multiple of n
 * */
#define ROUNDDOWN(a, n) ({                                          \
            size_t __a = (size_t)(a);                               \
            (typeof(a))(__a - __a % (n));                           \
        })
...
#endif

printfmt.c还依赖一个头文件riscv.h,这个头文件主要定义了若干和riscv架构相关的宏,尤其是将一些内联汇编的代码封装成宏,使得我们更方便地使用内联汇编来读写寄存器。当然这里我们还没有用到它的强大功能。

// libs/riscv.h
...
#define read_csr(reg) ({ unsigned long __tmp; \
  asm volatile ("csrr %0, " #reg : "=r"(__tmp)); \
  __tmp; })
//通过内联汇编包装了 csrr 指令为 read_csr() 宏
#define write_csr(reg, val) ({ \
  if (__builtin_constant_p(val) && (unsigned long)(val) < 32) \
    asm volatile ("csrw " #reg ", %0" :: "i"(val)); \
  else \
    asm volatile ("csrw " #reg ", %0" :: "r"(val)); })
...

到现在,我们已经看过了一个最小化的内核的各个部分,虽然一些部分没有逐行细读,但我们也知道它在做什么。

是不是感觉好麻烦啊!输出一个字符都那么麻烦。那是肯定的噢,可以稍微喘下气,脑子里回忆一下,我们是怎么一层一层剥开,又是如何一层一层包装的。好玩吧!

但一直到现在我们还没进行过编译。下面就把它编译一下跑起来。

最后更新于