实作系统呼叫与简易的 Shell

在先前的文章中,我们已经探讨过:

  • 中断与异常的处理
  • UNIX-Like Shell 的实作方式

在本篇文章中,作者会尝试实作基本的系统呼叫以及 Shell 在 mini-riscv-os 当中。

透过 Shell 学习 fork 与 exit 等系统呼叫

系统呼叫 (System Call) 由作业系统提供,若 User space 的应用程序调用了系统呼叫,作业系统便会从 User space 切换至 Kernel space,待作业系统处理完该系统呼叫後才会切换回 User space。

上图取自 Uppsala University 的作业系统教材

以上是一个最精简的 UNIX Shell 的流程图,Shell 会读取来自使用者输入的命令,并且呼叫 fork() 让作业系统复制一个状态与母程序一样的子程序。

#include <unistd.h>
pid_t fork(void);

On success, the PID of the child process is returned in the parent, and 0 is returned in the child.
-- Linux manual page

子程序被作业系统产生後,母与子程序会根据 fork() 的 return value 去判断当前的程序是前者还是後者:

  • 若执行的为母程序,则呼叫 wait() 系统呼叫等待子程序结束执行。
  • 若执行的为子程序,则将刚刚所分析完的使用者命令带入 exec() 系统呼叫,载入并执行指定的档案。
  • 当子程序结束後,母程序会进入下一个循环等待使用者下达新的命令。

补充 Copy On Write

当作业系统接受到 fork() 系统呼叫後,会将母程序的内容复制并产生子程序。
由於母程序与子程序所持有的 Stack 并非同一块,所以,若要在处理 fork() 时就完整的 Copy 一整份 Stack 的内容是非常浪费效能的。
针对此问题,许多作业系统都采用 Copy On Write 的技术,假设母程序在 Stack 上存放了一个 counter 变数,呼叫 fork() 产生子程序後,两者其实都是参考到同一块记忆体上的。这样的状况会一直持续到其中一方尝试修改 counter 变数,修改方会将 counter 的内容放到新的记忆体位址後再修改它所持有的那一份。这麽做就可以节省记忆体空间以及 fork() 所带来的额外开销!

进入正题:来实作系统呼叫与简易的 Shell 吧!

注意!
目前尚未实作 fork() 系统呼叫,下方若出现它的身影可以先略过。

实作系统呼叫

刚刚有提到:

若 User space 的应用程序调用了系统呼叫,作业系统便会从 User space 切换至 Kernel space。

要满足这样的需求,我们可以使用系统中断:

看到 Interrupt = 0 & Exception = 11 所对应到的描述 Environment call from M-mode,要产生 Environment call 只要使用 ecall 即可。
因此,我们使用组合语言去实现几个函式提供 C 语言呼叫:

.global gethid
gethid:
	li a7, 1
	ecall
	ret
.global fork
fork:
	li a7, 2
	ecall
	ret
.global exec
exec:
	li a7, 3
	ecall
	ret
.global exit
exit:
	li a7, 4
	ecall
	ret

只要呼叫上面所列出的任一个函式,这些函式都会将其系统呼叫对应到的号码放入 a7 暂存器後再执行 ecall

/* In trap_handler() */
    case 11:
        lib_puts("Environment call from M-mode!\n");
        do_syscall(ctx, &return_pc);
        break;

从 Machin mode 产生的 Environment call 会交由 do_syscall() 处理:

void do_syscall(struct context *ctx, uint32_t *pc)
{
	uint32_t syscall_num = ctx->a7;
	int ppid = get_current_task();
	debug_lib_puts("syscall_num: %d\n", syscall_num);
	switch (syscall_num)
	{
	case 1:
		ctx->a0 = sys_gethid((unsigned int *)(ctx->a0));
		break;
	case 2:
		/* fork
		 * returned value:
		 *	child process: 0
		 *  parent process: pid of child process
		 */
		ctx->a0 = task_copy(ppid, pc);
		break;
	case 3:
		// exec
		ctx->a0 = sys_exec((char *)ctx->a0, pc);
		goto ret;
		break;
	case 4:
		// exit
		sys_exit();
		*pc = &os_kernel;
		goto ret;
		break;
	default:
		lib_printf("Unknown syscall no: %d\n", syscall_num);
		ctx->a0 = -1;
	}

	*pc = *pc + 4;
ret:
	return;
}

do_syscall() 会以 a7 暂存器所储存的号码判断系统呼叫,并做出相对的处理,这边以 exec()exit() 为例:

  • exec() 被呼叫,系统会将希望执行的程序位址作为参数传入覆写掉 pc 的位址。
  • exit() 被呼叫,系统会把该 Task 移除并将 pc 复写为 os_kernel 的位址。

会将希望执行的程序位址作为参数传入 exec() 函式是因为该作业系统并没有完整的档案系统实作,所以没有所谓可执行档案的概念。
为了弥补这个缺陷,我选择事先构造 app table,让作业系统在开机执行 user_init() 时可以先注册基本的 User Program:

void user_init()
{
	user_app_init();
	user_app_register("info", &show_info);
	user_app_register("clear", &clear);
	task_create(&sh);
}

当然,这些 Program 也只是简易的函式,然後透过函式指标的方式注册到 OS。

void show_info()
{
	lib_puts("Wellcome to toothpasteOS\n");
	lib_puts("Version: 1.0\n");
	lib_puts("Note: Derived from mini-riscv-os\n");
	lib_puts("Author: Ian Chen\n");
	exit();
}

实作 mini shell

在实作 mini shell 之前,笔者其实也发现了一些问题:

  • 若在 lib_puts() 执行时(也就是使用者还没输入命令给 Shell 之前)发生时间中断,等到处理完中断再回到该 Task 时就会无法正确执行。
  • 若不小心存取到不存在的记忆体空间,作业系统会不断出现 Fault load 或是 Fault store 的状况。
  • 因为系统还没实作 fork 跟 file system,所以不可能把 unix shell 的那一套方法直接拿来用。

这些问题笔者也花了一点时间得到了(可能不是非常好的)解答:

  1. lib_puts() 执行时前後需特别关闭/开启 interrupt enable:
/* Disable timer interrupt*/
w_mie(r_mie() & ~(1 << 7));
/* Enable timer interrupt*/
w_mie(r_mie() | MIE_MTIE);

BTW,请先将 exteral_handler() 中处理 UART 中断的 handler 移除,不然在使用者输入字元的当下系统会直接把 uart register 的内容印出来,等到 shell 要用的时候就已经读不到内容了。

  1. 让 Program counter 执行下一条指令
    如果中断与异常处理完成後,Program counter 还是继续执行发生异常的指令,就有可能会造成死循环,因此,只要稍微修改 trap_handler() 即可避免该问题:
case 5:
      // Fault load!
      return_pc = return_pc + 4;
      break;
case 7:
      // Fault store!
      return_pc = return_pc + 4;
      break;
  1. 改用 task_create() 替代
    刚刚有提到笔者构造了一个 user table 用来存放一些简易的 user program。因此,我们只要在读取命令以後找到命令所对应的函式,再丢给 task_create() 就可以当作 fork() 的替代方案啦!
#include "os.h"
void sh()
{
    char input[50];
    int ready = 1;
    while (ready)
    {
        lib_puts("$ ");
        lib_gets(input);
        ready = 0;
        user_app_t *app_table = get_app_table();
        int i = 0;
        for (; i < APP_NUM; i++)
        {
            if (strcmp(app_table[i].path, input) >= 0)
            {
                break;
            }
        }
        if (i == APP_NUM)
        {
            lib_printf("shell: %s: command not found.\n", input);
        }
        else if (task_create(app_table[i].task) > 0)
        {
            lib_printf("shell: task[%s] is created! \n", app_table[i].path);
        }
        else
        {
            lib_printf("Only allow 30 tasks to run simultaneously.\n");
        }
        lib_delay(1000);
        ready = 1;
        w_mie(r_mie() | MIE_MTIE);
    }
}

此外,为了确保使用者在输入命令後,系统可以立刻处理其需求,我缩短了作业系统排程切换的时间并加入 lib_delay(),以保证系统会马上切换到 user program 进行处理。

总结

这次的实验让使用者能够体验与 mini-riscv-os 互动的快乐(?)
不过这些更动其实还没 patch-back 回 mini-riscv-os 之中,而是修改在我的牙膏 OS 专案底下,主要原因有几点:

  • 改动的幅度有点大。
  • 已实现的系统呼叫并没有被 shell 完整的运用。
  • 应改以其他方式实现任务的排程,而非使用 Timer Interrupt。
  • 在处理 UART 中断的当下应该把 uart register 的内容 buffer 起来,等到 lib_puts() 要用时再存取 buffer 就好。

即使该专案仍有诸多缺点,但作为一个学习作业系统的敲门砖,我相信它还是有其价值存在,也欢迎各位赏我的牙膏 OS 一个 Star。
最後,也感谢 austin362667 贡献了 gethid() 的实作,构建了系统呼叫的雏形。

Reference


<<:  A First Set of Refactorings

>>:  数学案例说明WEB 3.0时代,不可避免遭遇的数值正确性-by a Java Devops

Day 3 基本范例

前言 今天会介绍一些 flask 的基本函式,所以我们还没有正式开始写专案。这些基本的函式十分常用,...

Swift 新手-使用者介面(UX/UI/Core)

什麽是使用者介面? 使用者介面是介於使用者与硬体而设计彼此之间互动沟通相关软件,目的在使得使用者能够...

Day8 区块元素与行内元素

网页容器概念 网页上的内容可以被分为排版用的容器(ex: <div> )与元素(ex:...

找LeetCode上简单的题目来撑过30天啦(DAY16)

ok,今天挑战同一张证照第三次失败了,有够难过的,但至少分述有越来越接近啦,再接再厉罗 题号:55 ...

Day28 跟着官方文件学习Laravel-cache

当我们在读取DB资料时可能会占用大量 CPU 的资源让请求需要花几秒钟完成,这种情况我们会使用缓存,...