Skip to content

ICS PA 3

Posted on:2022.01.01

TOC

Open TOC

ICS PA 3

最简单的操作系统

在 PA 中使用的操作系统叫 Nanos-lite,它是南京大学操作系统 Nanos 的裁剪版。

Nanos-lite 是运行在 AM 之上,AM 的 API 在 Nanos-lite 中都是可用的。虽然操作系统对我们来说是一个特殊的概念,但在 AM 看来,它只是一个调用 AM API 的普通 C 程序而已。

Nanos-lite 的实现可以是架构无关的。可以在 native 上调试你编写的 Nanos-lite。

用户进程与用户程序:

举一个简单的例子吧,如果你打开了记事本 3 次,计算机上就会有 3 个记事本进程在运行,但磁盘中的记事本程序只有一个。

由于 Nanos-lite 本质上也是一个 AM 程序,我们可以采用相同的方式来编译 / 运行 Nanos-lite:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite$ make ARCH=riscv32-nemu run

回顾一下编译运行的过程:

# Building nanos-lite-run [riscv32-nemu]
# Building am-archive [riscv32-nemu]
# Building klib-archive [riscv32-nemu]
# Creating image [riscv32-nemu]

得到 Nanos-lite 的 bin 镜像后,再调用解释器以 batch mode 运行该镜像:

make -C /home/vgalaxy/ics2021/nemu ISA=riscv32 run ARGS="-b -l /home/vgalaxy/ics2021/nanos-lite/build/nemu-log.txt" IMG=/home/vgalaxy/ics2021/nanos-lite/build/nanos-lite-riscv32-nemu.bin
make[1]: Entering directory '/home/vgalaxy/ics2021/nemu'
+ LD /home/vgalaxy/ics2021/nemu/build/riscv32-nemu-interpreter
/home/vgalaxy/ics2021/nemu/build/riscv32-nemu-interpreter -b -l /home/vgalaxy/ics2021/nanos-lite/build/nemu-log.txt /home/vgalaxy/ics2021/nanos-lite/build/nanos-lite-riscv32-nemu.bin

类似在 NEMU 上运行 NEMU

来看一下操作系统的 main 函数:

int main() {
extern const char logo[];
printf("%s", logo);
Log("'Hello World!' from Nanos-lite");
Log("Build time: %s, %s", __TIME__, __DATE__);
init_mm();
init_device();
init_ramdisk();
#ifdef HAS_CTE
init_irq();
#endif
init_fs();
init_proc();
Log("Finish initialization");
#ifdef HAS_CTE
yield();
#endif
panic("Should not reach here");
}

其中 HAS_CTE 对应上下文扩展,目前尚未定义。

注意 Nanos-lite 中定义的 Log() 宏并不是 NEMU 中定义的 Log() 宏。在 Nanos-lite 中,Log() 宏通过你在 klib 中编写的 printf() 输出,最终会调用 TRM 的 putch()

所以需要扩展 printf 的修饰符:

case 'p':
sprint_s(out,"0x");
sprint_x(out,va_arg(ap,unsigned int),-1);
break;

64 位的实现参考:

#include <stdio.h>
int main() {
int a = 1;
int *b = &a;
printf("%p %p %p\n", &a, b, &b);
printf("0x%lx 0x%lx 0x%lx\n", (unsigned long)&a, (unsigned long)b, (unsigned long)&b);
}

来自操作系统的新需求

需要一种可以限制入口执行流切换方式。

为了阻止程序将执行流切换到操作系统的任意位置,硬件中逐渐出现保护机制相关的功能:在硬件中加入一些与特权级检查相关的门电路(例如比较器电路),如果发现了非法操作,就会抛出一个异常信号,让 CPU 跳转到一个约定好的目标位置,并进行后续处理。

以支持现代操作系统的 RISC-V 处理器为例,它们存在 M, S, U 三个特权模式,分别代表机器模式、监管者模式和用户模式。M 模式特权级最高,U 模式特权级最低,低特权级能访问的资源,高特权级也能访问。

通常来说,操作系统运行在 S 模式,因此有权限访问所有的代码和数据;而一般的程序运行在 U 模式,这就决定了它只能访问 U 模式的代码和数据。这样,只要操作系统将其私有代码和数据放 S 模式中,恶意程序就永远没有办法访问到它们。

PS:Meltdown 和 Spectre 这两个大名鼎鼎的硬件漏洞,打破了特权级的边界,恶意程序在特定的条件下可以以极高的速率窃取操作系统的信息。

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite$ sudo cat /proc/cpuinfo
...
model name : Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz
...
bugs : spectre_v1 spectre_v2 spec_store_bypass swapgs itlb_multihit
...

根据 KISS 法则,我们并不打算在 NEMU 中加入保护机制。我们让所有用户进程都运行在最高特权级,虽然所有用户进程都有权限执行所有指令,不过由于 PA 中的用户程序都是我们自己编写的,一切还是在我们的控制范围之内。

异常响应机制

为了实现最简单的操作系统,硬件还需要提供一种可以限制入口的执行流切换方式。这种方式就是自陷指令,程序执行自陷指令之后,就会陷入到操作系统预先设置好的跳转目标。这个跳转目标也称为异常入口地址。

CSAPP Chapter 8: exception

  • interrupt
  • trap
  • fault
  • abort

The coordination between hardware and software (OS).

这一过程是 ISA 规范的一部分,以 riscv32 为例:

riscv32 提供 ecall 指令作为自陷指令,并提供一个 mtvec 寄存器来存放异常入口地址。为了保存程序当前的状态,riscv32 提供了一些特殊的系统寄存器,叫控制状态寄存器 (CSR 寄存器)。在 PA 中,我们只使用如下 3 个 CSR 寄存器:

riscv32 触发异常后硬件的响应过程如下:

  1. 将当前 PC 值保存到 mepc 寄存器
  2. 在 mcause 寄存器中设置异常号
  3. 从 mtvec 寄存器中取出异常入口地址
  4. 跳转到异常入口地址

由于异常入口地址是硬件和操作系统约定好的,接下来的处理过程将会由操作系统来接管,操作系统将视情况决定是否终止当前程序的运行。若决定无需杀死当前程序,等到异常处理结束之后,就根据之前保存的信息恢复程序的状态,并从异常处理过程中返回到程序触发异常之前的状态。具体地:

这些程序状态 (mepc, mstatus, mcause) 必须由硬件来保存吗?能否通过软件来保存?为什么?

状态机视角下的异常响应机制

程序是个 S = <R, M> 的状态机。

扩充之后的寄存器可以表示为 R = {GPR, PC, SR},其中 SR 为系统寄存器。

添加异常响应机制之后,我们允许一条指令的执行会失败。为了描述指令执行失败的行为,我们可以假设 CPU 有一条虚构的指令 raise_intr,执行这条虚构指令的行为就是上文提到的异常响应过程:

SR[mepc] <- PC
SR[mcause] <- 一个描述失败原因的号码
PC <- SR[mtvec]

如果一条指令执行成功,其行为和之前介绍的 TRM 与 IOE 相同;如果一条指令执行失败,其行为等价于执行了虚构的 raise_intr 指令。

事实上,我们可以把这些失败的条件表示成一个函数 fex: S -> {0, 1},给定状态机的任意状态 Sfex(S) 都可以唯一表示当前 PC 指向的指令是否可以成功执行。

最后,异常响应机制的加入还伴随着一些系统指令的添加。这些指令除了用于专门对状态机中的 SR 进行操作之外,本质上和 TRM 的计算指令没有太大区别。

将上下文管理抽象成 CTE

硬件提供的上述在操作系统和用户程序之间切换执行流的功能,在操作系统看来,都可以划入上下文管理的一部分。

与 IOE 一样,上下文管理的具体实现也是架构相关的。为了遵循 AM 的精神,我们需要将不同架构的上下文管理功能抽象成统一的 API,需要的信息如下:

在处理过程中,操作系统可能会读出上下文中的一些寄存器,根据它们的信息来进行进一步的处理。例如操作系统读出 PC 所指向的非法指令,看看其是否能被模拟执行,如用软件模拟浮点指令的执行。如果无法处理,那就 UB 吧,如栈溢出。

不过,AM 究竟给程序提供了多大的栈空间呢?

通过追踪堆区的创建可知:

Area heap = RANGE(&_heap_start, PMEM_END);

其中 RANGE 宏的定义为:

#define RANGE(st, ed) (Area) { .start = (void *)(st), .end = (void *)(ed) }

PMEM_END 的定义为:

extern char _pmem_start;
#define PMEM_SIZE (128 * 1024 * 1024)
#define PMEM_END ((uintptr_t)&_pmem_start + PMEM_SIZE)

_pmem_start 定义在链接选项 LDFLAGS 中,位于 abstract-machine/scripts/platform/nemu.mk

LDFLAGS += -T $(AM_HOME)/scripts/linker.ld \
--defsym=_pmem_start=0x80000000 --defsym=_entry_offset=0x0

_heap_start 定义在链接脚本文件 abstract-machine/scripts/linker.ld 中:

_stack_top = ALIGN(0x1000);
. = _stack_top + 0x8000;
_stack_pointer = .;
end = .;
_end = .;
_heap_start = ALIGN(0x1000);

从而可知堆区的大小与 _heap_start 相关,一直到 0x88000000,而栈的大小为 0x8000

对于切换原因,我们只需要定义一种统一的描述方式即可。CTE 定义了名为事件的如下数据结构,见 abstract-machine/am/include/am.h

// An event of type @event, caused by @cause of pointer @ref
typedef struct {
enum {
EVENT_NULL = 0,
EVENT_YIELD, EVENT_SYSCALL, EVENT_PAGEFAULT, EVENT_ERROR,
EVENT_IRQ_TIMER, EVENT_IRQ_IODEV,
} event;
uintptr_t cause, ref;
const char *msg;
} Event;

其中 event 表示事件编号,causeref 是一些描述事件的补充信息,msg 是事件信息字符串,我们在 PA 中只会用到 event

对于上下文,我们只能将描述上下文的结构体类型名统一成 Context

// Arch-dependent processor context
typedef struct Context Context;

至于其中的具体内容,就无法进一步进行抽象了。这主要是因为不同架构之间上下文信息的差异过大。对于 riscv32 而言,其定义位于 abstract-machine/am/include/arch/riscv32-nemu.h

struct Context {
// TODO: fix the order of these members to match trap.S
uintptr_t mepc, mcause, gpr[32], mstatus;
void *pdir;
};

因此,在操作系统中对 Context 成员的直接引用,都属于架构相关的行为,会损坏操作系统的可移植性。不过大多数情况下,操作系统并不需要单独访问 Context 结构中的成员。CTE 也提供了一些的接口,来让操作系统在必要的时候访问它们,从而保证操作系统的相关代码与架构无关。

最后还有另外两个统一的 API:

bool cte_init (Context *(*handler)(Event ev, Context *ctx));
void yield (void);

穿越时空的旅程

硬件准备

RTFM

  • Unprivileged ISA: Chapter 10
  • Privileged Architecture: Chapter 2/3

首先我们需要实现如下指令:

csrrw rd, csr, rs1
t = CSRs[csr]; CSRs[csr] = x[rs1]; x[rd] = t
csrrs rd, csr, rs1
t = CSRs[csr]; CSRs[csr] = t | x[rs1]; x[rd] = t
csrrc rd, csr, rs1
t = CSRs[csr]; CSRs[csr] = t & ~x[rs1]; x[rd] = t

由此衍生出两个伪指令:

csrr rd, csr
x[rd] = CSRs[csr]
i.e. csrrs rd, csr, x0
csrw csr, rs1
CSRs[csr] = x[rs1]
i.e. csrrs x0, csr, rs1

上述指令均属于 RV32I Base Integer Instruction。

还有两个特殊的指令:

ecall 指令在 RV32I Base Integer Instruction 部分和 Machine-Mode Privileged Instruction 部分均有所介绍。而 mret 指令属于 Machine-Mode Privileged Instruction。

注意,ecall 指令将当前 PC 保存到 epc 中,而 mret 指令将当前 PC 设置为 epc:

ECALL and EBREAK cause the receiving privilege mode's epc register to be set to the address of the ECALL or EBREAK instruction itself, not the address of the following instruction.
xRET sets the pc to the value stored in the xepc register.

译码部分略。

对于指令的实现,首先我们需要找到 CSR 寄存器在 NEMU 中对应的抽象,然而并没有找到,于是使用了 PA2 中未使用的 RTL 临时寄存器

rtl.h
#define s0 (&tmp_reg[0])
#define s1 (&tmp_reg[1])
#define s2 (&tmp_reg[2])
#define t0 (&tmp_reg[3])

其对应如下:

s0 - mstatus
t0 - mtvec
s1 - mepc
s2 - mcause

下面是指令的实现,我们新建 system.h:

#include <isa.h>
vaddr_t* CSR_dispatch(uint32_t index) {
switch (index) {
case 0x300: return s0; // mstatus
case 0x305: return t0; // mtvec
case 0x341: return s1; // mepc
case 0x342: return s2; // mcause
default: panic("Unsupported CSR");
}
}
def_EHelper(mret) {
rtl_li(s, &s->dnpc, *s1);
}
def_EHelper(ecall) {
word_t mtvec = isa_raise_intr(11, s->pc); // EVENT_YIELD
rtl_li(s, &s->dnpc, mtvec);
}
def_EHelper(csrrw) {
uint32_t index = id_src2->imm;
vaddr_t* csr = CSR_dispatch(index);
vaddr_t t = *csr;
rtlreg_t rs1 = *dsrc1;
*csr = rs1;
*ddest = t;
}
def_EHelper(csrrs) {
uint32_t index = id_src2->imm;
vaddr_t* csr = CSR_dispatch(index);
vaddr_t t = *csr;
rtlreg_t rs1 = *dsrc1;
*csr = t | rs1;
*ddest = t;
}
def_EHelper(csrrc) {
uint32_t index = id_src2->imm;
vaddr_t* csr = CSR_dispatch(index);
vaddr_t t = *csr;
rtlreg_t rs1 = *dsrc1;
*csr = t & ~rs1;
*ddest = t;
}

其中的核心为 CSR_dispatch,参考 manual 中的一段话:

The standard RISC-V ISA sets aside a 12-bit encoding space (csr[11:0]) for up to 4,096 CSRs.

也就是对于 I-type 的 csrr 系指令而言,其立即数中存放的是 CSR 寄存器在一段空间中的索引。于是我们 RTFM 找到索引,返回 NEMU 中的对应的寄存器即可。

这里包含头文件 isa.h 是为了调用 isa_raise_intr

#include <rtl/rtl.h>
// s0 - mstatus
// t0 - mtvec
// s1 - mepc
// s2 - mcause
// 1. 将当前 PC 值保存到 mepc 寄存器
// 2. 在 mcause 寄存器中设置异常号
// 3. 从 mtvec 寄存器中取出异常入口地址
// 4. 跳转到异常入口地址
word_t isa_raise_intr(word_t NO, vaddr_t epc) {
/* TODO: Trigger an interrupt/exception with ``NO''.
* Then return the address of the interrupt/exception vector.
*/
*s1 = epc;
*s2 = NO;
return *t0;
}

设置异常入口地址

调用轨迹:

main -> init_irq -> cte_init

下面来看 cte_init 函数:

bool cte_init(Context*(*handler)(Event, Context*)) {
// initialize exception entry
asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));
// register event handler
user_handler = handler;
return true;
}

内联汇编语句将 __am_asm_trap 的地址(异常入口地址)存入 mtvec 寄存器中,并注册一个事件处理回调函数。

触发自陷操作

调用轨迹:

main -> yield

下面来看 yield 函数:

void yield() {
asm volatile("li a7, -1; ecall");
}

li a7, -1 不知道在干嘛……

详见后面的系统调用部分,用于区分异常事件号和系统调用事件号

ecall 指令让 NEMU 跳转到异常入口地址。注意 Exception Code 为 11,对应 Environment call from M-mode。需要对应修改 abstract-machine/am/include/am.h 中的 Context 结构体。

为了让 DiffTest 机制正确工作,还需要将 mstatus 初始化为 0x1800,目前由硬件实现(感觉不太妥)。

保存上下文

成功跳转到异常入口地址之后,我们就要在软件上开始真正的异常处理过程。这一过程由 __am_asm_trap 完成:

#define CONTEXT_SIZE ((32 + 3 + 1) * XLEN)
#define OFFSET_SP ( 2 * XLEN)
#define OFFSET_CAUSE (32 * XLEN)
#define OFFSET_STATUS (33 * XLEN)
#define OFFSET_EPC (34 * XLEN)
.align 3
.globl __am_asm_trap
__am_asm_trap:
addi sp, sp, -CONTEXT_SIZE
MAP(REGS, PUSH)
csrr t0, mcause
csrr t1, mstatus
csrr t2, mepc
STORE t0, OFFSET_CAUSE(sp)
STORE t1, OFFSET_STATUS(sp)
STORE t2, OFFSET_EPC(sp)
# set mstatus.MPRV to pass difftest
li a0, (1 << 17)
or t1, t1, a0
csrw mstatus, t1
mv a0, sp
jal __am_irq_handle
LOAD t1, OFFSET_STATUS(sp)
LOAD t2, OFFSET_EPC(sp)
csrw mstatus, t1
csrw mepc, t2
MAP(REGS, POP)
addi sp, sp, CONTEXT_SIZE
mret

在调用 __am_irq_handle 之前,需要对上下文进行保存:

下面的重点是上下文结构的组织,我们调整 Context 结构体使之与 __am_asm_trap 的行为一致:

struct Context {
// TODO: fix the order of these members to match trap.S
uintptr_t gpr[32], mcause, mstatus, mepc;
void *pdir;
};

大概思路是,先让栈指针 sp 减去 CONTEXT_SIZE,然后依次将上下文信息存入栈中:

| | <-- prev sp
| |
| mepc |
| mstatus |
| mcause |
| x31 |
| x30 |
...
| x3 |
| |
| x1 |
| | <-- sp

其中 x2 寄存器为 sp。最后通过 mv a0, sp 指令准备好参数(第一个参数通过寄存器 a0 传递)。

事件分发

Context* __am_irq_handle(Context *c) {
printf("mepc: %x, mcause: %u, mstatus: %u\n", c->mepc, c->mcause, c->mstatus);
if (user_handler) {
Event ev = {0};
switch (c->mcause) {
case EVENT_YIELD: ev.event = EVENT_YIELD; break;
case EVENT_SYSCALL: ev.event = EVENT_SYSCALL; break;
case EVENT_PAGEFAULT: ev.event = EVENT_PAGEFAULT; break;
default: ev.event = EVENT_ERROR; break;
}
c = user_handler(ev, c);
assert(c != NULL);
}
return c;
}

来到 __am_irq_handle 中,这里根据执行流切换的原因打包成事件,然后调用在 cte_init() 中注册的事件处理回调函数,将事件交给 Nanos-lite 来处理:

static Context* do_event(Event e, Context* c) {
switch (e.event) {
case EVENT_YIELD: printf("EVENT_YIELD\n"); break;
case EVENT_SYSCALL: printf("EVENT_SYSCALL\n"); break;
case EVENT_PAGEFAULT: printf("EVENT_PAGEFAULT\n"); break;
default: panic("Unhandled event ID = %d", e.event);
}
return c;
}

重点是 __am_irq_handle 如何读取上下文信息的,我们参考反汇编:

8000062c <__am_irq_handle>:
8000062c: fd010113 addi sp,sp,-48
80000630: 08452683 lw a3,132(a0)
80000634: 08052603 lw a2,128(a0)
80000638: 08852583 lw a1,136(a0)
8000063c: 02812423 sw s0,40(sp)
80000640: 00050413 mv s0,a0
80000644: 80002537 lui a0,0x80002
80000648: 13850513 addi a0,a0,312 # 80002138 <_end+0xfffec138>
8000064c: 02112623 sw ra,44(sp)
80000650: 291000ef jal ra,800010e0 <printf>
...

我们接着上面的栈:

| | <-- prev prev sp
| |
| mepc |
| mstatus |
| mcause |
| x31 |
| x30 |
...
| x3 |
| |
| x1 |
| | <-- prev sp
...
| | <-- sp

寄存器 a0 中存放着 prev sp,三条 lw 语句为 printf 准备参数,a1 为偏移 34×4 处的 epc,a2 为偏移 32×4 处的 mcause,a3 为偏移 33×4 处的 mstatus,与 printf 一致。

恢复上下文

代码将会一路返回到 trap.S__am_asm_trap() 中,__am_asm_trap() 将根据之前保存的上下文内容,恢复程序的状态,其过程与保存上下文基本上对应,不再赘述。

在之前的硬件实现中,mret 指令将当前 PC 设置为 epc,即 ecall 指令的地址。于是由软件在适当的地方对保存的 PC 加上 4,这里选择在 __am_asm_trap 中恢复上下文前对 PC 加上 4。

事实上,自陷只是其中一种异常类型。有一种故障类异常,它们返回的 PC 和触发异常的 PC 是同一个,例如缺页异常,在系统将故障排除后,将会重新执行相同的指令进行重试,因此异常返回的 PC 无需加 4。所以根据异常类型的不同,有时候需要加 4,有时候则不需要加。

CISC 都交给硬件来做,而 RISC 则交给软件来做。

代码最后会返回到 Nanos-lite 触发自陷的代码位置,然后继续执行。在它看来,这次时空之旅就好像没有发生过一样。

异常处理的踪迹 - etrace

你也许认为在 CTE 中通过 printf() 输出信息也可以达到类似的效果,但这一方案和在 NEMU 中实现 etrace 还是有如下区别:

实现略。

加载第一个用户程序

加载的过程就是把可执行文件中的代码和数据放置在正确的内存位置,然后跳转到程序入口。

为了实现 loader() 函数,我们需要解决以下问题:

  1. 可执行文件
  2. 可执行文件中的代码和数据
  3. 正确的内存位置

关于程序从何而来,可以参考一篇文章:COMPILER, ASSEMBLER, LINKER AND LOADER: A BRIEF STORY

可执行文件

用户程序运行在操作系统之上,由于运行时环境的差异,我们不能把编译到 AM 上的程序放到操作系统上运行。为此,我们准备了一个新的子项目 Navy-apps,专门用于编译出操作系统的用户程序。

Navy 的 Makefile 组织和 abstract-machine 非常类似。

navy-apps/libs/libc 中是一个名为 Newlib 的项目,它是一个专门为嵌入式系统提供的 C 库,库中的函数对运行时环境的要求极低。

用户程序的入口位于 navy-apps/libs/libos/src/crt0/start/riscv32.S 中的 _start() 函数:

.globl _start
_start:
move s0, zero
jal call_main

_start() 函数会调用 navy-apps/libs/libos/src/crt0/crt0.c 中的 call_main() 函数:

void call_main(uintptr_t *args) {
char *empty[] = {NULL };
environ = empty;
exit(main(0, empty, empty));
assert(0);
}

然后调用用户程序的 main() 函数,从 main() 函数返回后会调用 exit() 结束运行。

我们要在 Nanos-lite 上运行的第一个用户程序是 navy-apps/tests/dummy/dummy.c

int main() {
return _syscall_(SYS_yield, 0, 0, 0);
}

为了避免和 Nanos-lite 的内容产生冲突,我们约定目前用户程序需要被链接到内存位置 0x83000000 附近。Navy 已经设置好了相应的选项,见 navy-apps/scripts/riscv32.mk 中的 LDFLAGS 变量:

CROSS_COMPILE = riscv64-linux-gnu-
LNK_ADDR = $(if $(VME), 0x40000000, 0x83000000)
CFLAGS += -fno-pic -march=rv32g -mabi=ilp32
LDFLAGS += -melf32lriscv --no-relax -Ttext-segment $(LNK_ADDR)

navy-apps/tests/dummy/ 目录下执行:

make ISA=riscv32

编译成功后把 navy-apps/tests/dummy/build/dummy-riscv32 手动复制到 nanos-lite/build/ramdisk.img

使用 cat 命令重定向输出

然后在 nanos-lite/ 目录下执行

make ARCH=riscv32-nemu

会生成 Nanos-lite 的可执行文件,编译期间会把 ramdisk 镜像文件 nanos-lite/build/ramdisk.img 包含进 Nanos-lite 成为其中的一部分,在 nanos-lite/src/resources.S 中实现:

.section .data
.global ramdisk_start, ramdisk_end
ramdisk_start:
.incbin "build/ramdisk.img"
ramdisk_end:

总结来说,可执行文件位于 ramdisk 偏移为 0 处,访问它就可以得到用户程序的第一个字节。

可执行文件中的代码和数据

可执行文件为 ELF 文件格式。

ELF 是 GNU/Linux 可执行文件的标准格式,这是因为 GNU/Linux 遵循 System V ABI (Application Binary Interface).

ELF 文件提供了两个视角来组织一个可执行文件:

我们现在关心的是如何加载程序,因此我们重点关注 segment 的视角。ELF 中采用 program header table 来管理 segment,program header table 的一个表项描述了一个 segment 的所有属性,包括类型、虚拟地址、标志、对齐方式、以及文件内偏移量和 segment 大小。

我们可以通过判断 segment 的 Type 属性是否为 PT_LOAD 来判断一个 segment 是否需要加载。

下面是对上述可执行文件的分析,通过 readelf 命令:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ readelf -a ramdisk.img

得到 ELF Header:

ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x830003dc
Start of program headers: 52 (bytes into file)
Start of section headers: 27772 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 3
Size of section headers: 40 (bytes)
Number of section headers: 11
Section header string table index: 10

Program Headers:

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x83000000 0x83000000 0x04df8 0x04df8 R E 0x1000
LOAD 0x005000 0x83005000 0x83005000 0x00898 0x008d4 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10

我们研究一下映射关系:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ hexdump ramdisk.img -n 52
0000000 457f 464c 0101 0001 0000 0000 0000 0000
0000010 0002 00f3 0001 0000 03dc 8300 0034 0000
0000020 6c7c 0000 0000 0000 0034 0020 0003 0028
0000030 000b 000a
0000034

下面是每个 segment 的信息:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ hexdump ramdisk.img -s 52 -n 96
0000034 0001 0000 0000 0000 0000 8300 0000 8300
0000044 4df8 0000 4df8 0000 0005 0000 1000 0000
0000054 0001 0000 5000 0000 5000 8300 5000 8300
0000064 0898 0000 08d4 0000 0006 0000 1000 0000
0000074 e551 6474 0000 0000 0000 0000 0000 0000
0000084 0000 0000 0000 0000 0006 0000 0010 0000
0000094

大概是每 32bits 记录一条信息。

正确的内存位置

注意到 naive_uload 将程序入口强制转换一个函数指针并调用:

void naive_uload(PCB *pcb, const char *filename) {
uintptr_t entry = loader(pcb, filename);
Log("Jump to entry = %p", entry);
((void(*)())entry) ();
}

所以只要对 segment 中的 VirtAddr 直接赋值就可以了。

可以参考下面的图示:

+-------+---------------+-----------------------+
| |...............| |
| |...............| | ELF file
| |...............| |
+-------+---------------+-----------------------+
0 ^ |
|<------+------>|
| | |
| |
| +----------------------------+
| |
Type | Offset VirtAddr PhysAddr |FileSiz MemSiz Flg Align
LOAD +-- 0x001000 0x03000000 0x03000000 +0x1d600 0x27240 RWE 0x1000
| | |
| +-------------------+ |
| | |
| | | | |
| | | | |
| | +-----------+ --- |
| | |00000000000| ^ |
| | --- |00000000000| | |
| | ^ |...........| | |
| | | |...........| +------+
| +--+ |...........| |
| | |...........| |
| v |...........| v
+-------> +-----------+ ---
| |
| |
Memory

FileSiz 通常不会大于相应的 MemSizMemSiz 多出的部分可能是 .bss 段,需要初始化为 0,也就是将 [VirtAddr + FileSiz, VirtAddr + MemSiz) 对应的物理区间清零。

实现

实现 nanos-lite/src/loader.c 中的 loader() 函数,参数目前不考虑:

// see /usr/include/elf.h to get the right type
// #define EM_RISCV 243 /* RISC-V */
// #define EM_X86_64 62 /* AMD x86-64 architecture */
#if defined(__ISA_AM_NATIVE__) || defined(__ISA_X86_64__)
# define EXPECT_TYPE EM_X86_64
#elif defined(__ISA_X86__)
# define EXPECT_TYPE panic("Unknown type")
#elif defined(__ISA_RISCV32__) || defined(__ISA_RISCV64__)
# define EXPECT_TYPE EM_RISCV
#elif defined(__ISA_MIPS32__)
# define EXPECT_TYPE panic("Unknown type")
#else
# error Unsupported ISA
#endif
size_t ramdisk_read(void *buf, size_t offset, size_t len);
static uintptr_t loader(PCB *pcb, const char *filename) {
char buf[64];
// check magic number
ramdisk_read(buf, 0, 8);
uint64_t magic_number = 0;
for (int i = 0; i < 8; ++i) magic_number = magic_number * 256 + buf[i];
assert(magic_number == 0x7f454c4601010100);
// check machine architecture
ramdisk_read(buf, 18, 2);
uint16_t type = 0;
for (int i = 1; i >= 0; --i) type = type * 256 + buf[i];
EXPECT_TYPE; // trigger panic()
assert(type == EXPECT_TYPE);
// get entry point address
uintptr_t entry_point_address = 0;
ramdisk_read(buf, 24, 4);
for (int i = 3; i >= 0; --i) entry_point_address = entry_point_address * 256 + buf[i];
// get program header basic infos
uint32_t program_header_index = 0;
uint32_t program_header_length = 0; // the size of one section
uint32_t program_header_number = 0;
ramdisk_read(buf, 28, 4);
for (int i = 3; i >= 0; --i) program_header_index = program_header_index * 256 + buf[i];
ramdisk_read(buf, 42, 2);
for (int i = 1; i >= 0; --i) program_header_length = program_header_length * 256 + buf[i];
ramdisk_read(buf, 44, 4);
for (int i = 1; i >= 0; --i) program_header_number = program_header_number * 256 + buf[i];
// Log("%u %u %u", program_header_index, program_header_length, program_header_number);
// handle items of program header
for (int i = 0; i < program_header_number; ++i) {
uint32_t type = 0;
uint32_t base = program_header_index + i * program_header_length;
ramdisk_read(buf, base, 4);
for (int i = 3; i >= 0; --i) type = type * 256 + buf[i];
if (type == 1) { // LOAD
uint32_t offset = 0;
uint32_t virtAddr = 0;
uint32_t fileSiz = 0;
uint32_t memSiz = 0;
ramdisk_read(buf, base + 4, 4);
for (int i = 3; i >= 0; --i) offset = offset * 256 + buf[i];
ramdisk_read(buf, base + 8, 4);
for (int i = 3; i >= 0; --i) virtAddr = virtAddr * 256 + buf[i];
ramdisk_read(buf, base + 16, 4);
for (int i = 3; i >= 0; --i) fileSiz = fileSiz * 256 + buf[i];
ramdisk_read(buf, base + 20, 4);
for (int i = 3; i >= 0; --i) memSiz = memSiz * 256 + buf[i];
// Log("0x%08x 0x%08x 0x%08x 0x%08x", offset, virtAddr, fileSiz, memSiz);
char * buf_malloc = (char *)malloc(fileSiz * sizeof(char) + 1);
ramdisk_read(buf_malloc, offset, fileSiz);
memcpy((void *)virtAddr, buf_malloc, fileSiz);
memset((void *)(virtAddr + fileSiz), 0, memSiz - fileSiz);
free(buf_malloc);
}
}
return entry_point_address;
}

这里的 ramdisk_read 的定义在 nanos-lite/src/ramdisk.c 中。

另外对 ELF 文件的魔数(只考虑了前 64 位,实际上应该是 128 位)和机器架构(AM 中定义的一些宏和 /usr/include/elf.h 中定义的机器架构)进行了检测。

只考虑了 32 位环境,所以无法在 native 上测试 Nanos-lite 实现,native 是 64 位环境。

实现通用 loader 可能需要参考 /usr/include/elf.h 中 ELF 文件结构的定义。

Navy 中还有一个叫 native 的 ISA,它与 AM 中名为 native 的 ARCH 机制有所不同,目前暂不使用。

梳理一下用户程序的加载过程:

main -> init_proc -> naive_uload -> loader

操作系统的运行时环境

在 PA2 中,我们根据具体实现是否与 ISA 相关,将运行时环境划分为两部分。但对于运行在操作系统上的程序,它们就不需要直接与硬件交互了。

作为资源管理者管理着系统中的所有资源,操作系统还需要为用户程序提供相应的服务。这些服务需要以一种统一的接口来呈现,用户程序也只能通过这一接口来请求服务。

这一接口就是系统调用

于是,系统调用把整个运行时环境分成两部分,一部分是操作系统内核区,另一部分是用户区。那些会访问系统资源的功能会放到内核区中实现,而用户区则保留一些无需使用系统资源的功能,比如 strcpy(),以及用于请求系统资源相关服务的系统调用接口。

系统调用

用户程序通过自陷指令来触发系统调用,触发了事件 EVENT_SYSCALL

硬件与操作系统部分(实现)

既然我们通过自陷指令来触发系统调用,那么对用户程序来说,用来向操作系统描述需求的最方便手段就是使用通用寄存器,参见 abstract-machine/am/include/arch/riscv32-nemu.h

#define GPR1 gpr[17] // a7
#define GPR2 gpr[10] // a0
#define GPR3 gpr[11] // a1
#define GPR4 gpr[12] // a2
#define GPRx gpr[10] // a0

前四个为系统调用参数寄存器,第一个记录系统调用事件编号。最后一个寄存器 GPRx 为系统调用返回值寄存器。

通用寄存器的选择参考 navy-apps/libs/libos/src/syscall.c

# define ARGS_ARRAY ("ecall", "a7", "a0", "a1", "a2", "a0")

TODO: RISC-V Linux 为什么没有使用 a0 来传递系统调用号呢?

我们还需要修改下面的地方:

#define EVENT_YIELD 1
#define EVENT_SYSCALL 2
def_EHelper(ecall) {
rtlreg_t a7 = gpr(17);
Log("GPR a7: %u", a7);
word_t exception_code;
switch (a7) {
// void yield() { asm volatile("li a7, -1; ecall"); }
case -1: exception_code = EVENT_YIELD; break;
default: exception_code = EVENT_SYSCALL; break;
}
word_t mtvec = isa_raise_intr(exception_code, s->pc);
rtl_li(s, &s->dnpc, mtvec);
#ifdef CONFIG_ETRACE
Log("ecall at " FMT_WORD ", mstatus: " FMT_WORD ", mepc: " FMT_WORD ", mcause: " FMT_WORD, s->pc, *s0, *s1, *s2);
Log("The machine trap-handler base address is " FMT_WORD, mtvec);
#endif
}

其中 gpr 定义在 nemu/src/isa/riscv32/local-include/reg.h

#define gpr(idx) (cpu.gpr[check_reg_idx(idx)]._32)

取出寄存器 a7 的值,若为 -1,则为异常事件 EVENT_YIELD,否则为异常事件 EVENT_SYSCALL

仍需要斟酌……

将异常事件编号 EVENT_YIELD 又改回来了,由此无法通过 DiffTest ……

发现 DiffTest 的 REF 中 EVENT_YIELDEVENT_SYSCALL 的编号均为 11,那就无法区分了……

注意若为异常事件 EVENT_SYSCALL,寄存器 a7 中的值即为系统调用事件编号!

static Context* do_event(Event e, Context* c) {
switch (e.event) {
case EVENT_YIELD: Log("EVENT_YIELD"); break;
case EVENT_SYSCALL: Log("EVENT_SYSCALL"); do_syscall(c); break;
case EVENT_PAGEFAULT: Log("EVENT_PAGEFAULT"); break;
default: panic("Unhandled event ID = %d", e.event);
}
return c;
}

针对异常事件 EVENT_SYSCALL,调用 nanos-lite/src/syscall.c 中的 do_syscall 函数,该函数参数为上下文信息:

void do_syscall(Context *c) {
uintptr_t a[4];
a[0] = c->GPR1;
intptr_t ret;
switch (a[0]) {
case SYS_exit: sys_exit(c->GPR2); break;
case SYS_yield: ret = sys_yield(); break;
default: panic("Unhandled syscall ID = %d", a[0]);
}
c->GPRx = ret;
}

取出 GPR1 即寄存器 a7,并进行事件分派。并将返回值存入 GPRx 中。

我们还需要将 navy-apps/libs/libos/src/syscall.h 中系统调用事件编号的定义复制到 nanos-lite/src/syscall.h

下面是相应的系统调用处理函数:

int sys_yield() {
yield();
return 0;
}
void sys_exit(int status) {
halt(status);
}

用户程序部分(接口)

Navy 已经为用户程序准备好了系统调用的接口了,即 navy-apps/libs/libos/src/syscall.c 中定义的 _syscall_() 函数:

intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) {
register intptr_t _gpr1 asm (GPR1) = type;
register intptr_t _gpr2 asm (GPR2) = a0;
register intptr_t _gpr3 asm (GPR3) = a1;
register intptr_t _gpr4 asm (GPR4) = a2;
register intptr_t ret asm (GPRx);
asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4));
return ret;
}
void _exit(int status) {
_syscall_(SYS_exit, status, 0, 0);
while (1);
}

上述代码会先把系统调用的参数依次放入寄存器中,然后执行自陷指令。由于寄存器和自陷指令都是 ISA 相关的,因此这里根据不同的 ISA 定义了不同的宏,来对它们进行抽象。CTE 会将这个自陷操作打包成一个系统调用事件 EVENT_SYSCALL,并交由 Nanos-lite 继续处理。

执行过程分析

我们结合用户程序的反汇编:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ riscv64-linux-gnu-objdump -d ramdisk.img
ramdisk.img: file format elf32-littleriscv
Disassembly of section .text:
83000094 <main>:
83000094: 00000693 li a3,0
83000098: 00000613 li a2,0
8300009c: 00000593 li a1,0
830000a0: 00100513 li a0,1
830000a4: 00000317 auipc t1,0x0
830000a8: 00830067 jr 8(t1) # 830000ac <_syscall_>
830000ac <_syscall_>:
830000ac: 00050893 mv a7,a0
830000b0: 00058513 mv a0,a1
830000b4: 00060593 mv a1,a2
830000b8: 00068613 mv a2,a3
830000bc: 00000073 ecall
830000c0: 00008067 ret
830000c4 <_exit>:
830000c4: 00000893 li a7,0
830000c8: 00000593 li a1,0
830000cc: 00000613 li a2,0
830000d0: 00000073 ecall
830000d4: 0000006f j 830000d4 <_exit+0x10>
...
830003dc <_start>:
830003dc: 00000413 li s0,0
830003e0: 004000ef jal ra,830003e4 <call_main>
830003e4 <call_main>:
830003e4: fe010113 addi sp,sp,-32
830003e8: 00c10613 addi a2,sp,12
830003ec: 830067b7 lui a5,0x83006
830003f0: 00060593 mv a1,a2
830003f4: 00000513 li a0,0
830003f8: 00112e23 sw ra,28(sp)
830003fc: 88c7a423 sw a2,-1912(a5) # 83005888 <__BSS_END__+0xffffffb4>
83000400: 00012623 sw zero,12(sp)
83000404: 00000097 auipc ra,0x0
83000408: c90080e7 jalr -880(ra) # 83000094 <main>
8300040c: 00000097 auipc ra,0x0
83000410: 080080e7 jalr 128(ra) # 8300048c <exit>
...
8300048c <exit>:
8300048c: ff010113 addi sp,sp,-16
83000490: 00000593 li a1,0
83000494: 00812423 sw s0,8(sp)
83000498: 00112623 sw ra,12(sp)
8300049c: 00050413 mv s0,a0
830004a0: 00000097 auipc ra,0x0
830004a4: 028080e7 jalr 40(ra) # 830004c8 <__call_exitprocs>
830004a8: 830067b7 lui a5,0x83006
830004ac: 8847a503 lw a0,-1916(a5) # 83005884 <__BSS_END__+0xffffffb0>
830004b0: 03c52783 lw a5,60(a0)
830004b4: 00078463 beqz a5,830004bc <exit+0x30>
830004b8: 000780e7 jalr a5
830004bc: 00040513 mv a0,s0
830004c0: 00000097 auipc ra,0x0
830004c4: c04080e7 jalr -1020(ra) # 830000c4 <_exit>

和运行结果来分析其执行过程:

[/home/vgalaxy/ics2021/nanos-lite/src/loader.c,100,naive_uload] Jump to entry = 0x830003dc
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: 1
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x830000bc, mstatus: 0x00001800, mepc: 0x830000bc, mcause: 0x00000002
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80
[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,8,do_event] EVENT_SYSCALL
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: -1
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x80000b74, mstatus: 0x00021800, mepc: 0x80000b74, mcause: 0x00000001
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80
[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,7,do_event] EVENT_YIELD
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:16 exec_mret] mret at 0x80000cb8, mstatus: 0x00021800, mepc: 0x80000b78, mcause: 0x00000001
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:16 exec_mret] mret at 0x80000cb8, mstatus: 0x00001800, mepc: 0x830000c0, mcause: 0x00000001
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: 0
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x830000d0, mstatus: 0x00001800, mepc: 0x830000d0, mcause: 0x00000002
[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80
[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,8,do_event] EVENT_SYSCALL
[src/cpu/cpu-exec.c:152 cpu_exec] nemu: HIT GOOD TRAP at pc = 0x80000734

其执行过程大概如下:

_start -> call_main -> main -> _syscall_(SYS_yield, 0, 0, 0) -> exit -> _exit

对于 _syscall_(SYS_yield, 0, 0, 0) 这个系统调用处理,经历了如下过程:

异常事件 EVENT_SYSCALL -> 系统调用事件 SYS_yield
ecall at 0x830000bc, mstatus: 0x00001800, mepc: 0x830000bc, mcause: 0x00000002
异常事件 EVENT_YIELD
ecall at 0x80000b74, mstatus: 0x00021800, mepc: 0x80000b74, mcause: 0x00000001
mret at 0x80000cb8, mstatus: 0x00021800, mepc: 0x80000b78, mcause: 0x00000001
mret at 0x80000cb8, mstatus: 0x00001800, mepc: 0x830000c0, mcause: 0x00000001

也就是一个嵌套ecall,注意外层的 mcause 并未恢复。

对于 _exit 系统调用处理,经历了如下过程:

异常事件 EVENT_SYSCALL -> 系统调用事件 sys_exit
ecall at 0x830000d0, mstatus: 0x00001800, mepc: 0x830000d0, mcause: 0x00000002
no mret

Linux 系统调用的相关信息

系统调用的踪迹 - strace

Linux 上有一个叫 strace 的工具,它可以记录用户程序进行系统调用的踪迹。

例如你可以通过 strace ls 来了解 ls 的行为,你甚至可以看到 ls 是如何被加载的;如果你对 strace 本身感兴趣,你还可以通过 strace strace ls 来研究 strace 是如何实现的。

事实上,我们也可以在 Nanos-lite 中实现一个简单的 strace,Nanos-lite 可以得到系统调用的所有信息,包括名字、参数和返回值。这也是为什么我们选择在 Nanos-lite 中实现 strace:系统调用是携带高层的程序语义的,但 NEMU 中只能看到底层的状态机。

实现略。

无法在 NEMU 中定义宏来控制 strace 的开关了……

操作系统之上的 TRM

标准输出

输出是通过 SYS_write 系统调用来实现的,修改 navy-apps/libs/libos/src/syscall.c

int _write(int fd, void *buf, size_t count) {
return _syscall_(SYS_write, fd, (intptr_t)buf, count);
}

添加系统调用处理函数:

int sys_write(int fd, void *buf, size_t count) {
if (fd == 1 || fd == 2) {
for (size_t i = 0; i < count; ++i) {
putch(*((char *)buf + i));
}
return count;
}
return -1;
}

其中的返回值通过查阅 man 2 write 可知:

On success, the number of bytes written is returned. On error, -1 is returned, and errno is set to indicate the cause of the error.

再加一个 case 就可以了:

case SYS_write:
ret = sys_write(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4);
Log("sys_write(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;

注意其中的一些强制类型转换

Navy 中提供了一个 hello 测试程序,它首先通过 write() 来输出一句话,然后通过 printf() 来不断输出(逐字符):

#include <unistd.h>
#include <stdio.h>
int main() {
write(1, "Hello World!\n", 13);
int i = 2;
volatile int j = 0;
while (1) {
j ++;
if (j == 10000) {
printf("Hello World from Navy-apps for the %dth time!\n", i ++);
j = 0;
}
}
return 0;
}

编译成功后,我们需要将其手动复制到 ramdisk.img 中:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/tests/hello/build$ cat hello-riscv32 > ../../../../nanos-lite/build/ramdisk.img

调用轨迹为:

  • write -> _write_r -> _write
  • printf -> _vfprintf_r -> __sfputs_r -> _fputc_r -> _putc_r -> __swbuf_r -> __swsetup_r -> __smakebuf_r -> _malloc_r -> ...

printf 的调用轨迹较为复杂,上面仅给出了调用到 malloc 的部分

堆区管理

堆区的使用情况是由 libc 来进行管理的,但堆区的大小却需要通过系统调用向操作系统提出更改。

调整堆区大小是通过 sbrk() 库函数来实现的,它的原型是:

void* sbrk(intptr_t increment);

用于将用户程序的 program break 增长 increment 字节,其中 increment 可为负数。

所谓 program break,就是用户程序的数据段 (data segment) 结束的位置。

我们知道链接的时候 ld 会默认添加一个名为 _end 的符号,来指示程序的数据段结束的位置。用户程序开始运行的时候,program break 会位于 _end 所指示的位置,意味着此时堆区的大小为 0。

malloc() 被第一次调用的时候,会通过 sbrk(0) 来查询用户程序当前 program break 的位置,之后就可以通过后续的 sbrk() 调用来动态调整用户程序 program break 的位置了。

在 Navy 的 Newlib 中,sbrk() 最终会调用 _sbrk()

调用轨迹为 sbrk -> _sbrk_r -> _sbrk

它在 navy-apps/libs/libos/src/syscall.c 中定义。框架代码让 _sbrk() 总是返回 -1,表示堆区调整失败,事实上,用户程序在第一次调用 printf() 的时候会尝试通过 malloc() 申请一片缓冲区,来存放格式化的内容。若申请失败,就会逐个字符进行输出。

为了实现 _sbrk() 的功能,我们还需要提供一个用于设置堆区大小的系统调用。在 GNU/Linux 中,这个系统调用是 SYS_brk,它接收一个参数 addr,用于指示新的 program break 的位置。_sbrk() 通过记录的方式来对用户程序的 program break 位置进行管理,其工作方式如下:

  1. program break 一开始的位置位于 _end
  2. 被调用时,根据记录的 program break 位置和参数 increment,计算出新 program break
  3. 通过 SYS_brk 系统调用来让操作系统设置新 program break
  4. SYS_brk 系统调用成功,该系统调用会返回 0,此时更新之前记录的 program break 的位置,并将旧 program break 的位置作为 _sbrk() 的返回值返回
  5. 若该系统调用失败,_sbrk() 会返回 -1

上述代码是在用户层的库函数中实现的,我们还需要在 Nanos-lite 中实现 SYS_brk 的功能。由于目前 Nanos-lite 还是一个单任务操作系统,空闲的内存都可以让用户程序自由使用,因此我们只需要让 SYS_brk 系统调用总是返回 0 即可,表示堆区大小的调整总是成功。

通过 man brk 可知,_sbrk() 对应

void *sbrk(intptr_t increment);

sys_brk 对应:

int brk(void *addr);

于是实现如下,修改 navy-apps/libs/libos/src/syscall.c

extern int _end;
int program_break = (int)(&_end);
void *_sbrk(intptr_t increment) {
int program_break_prev = program_break;
if (_syscall_(SYS_brk, program_break + increment, 0, 0) == 0) {
program_break = program_break + increment;
return (void *)program_break_prev;
}
return (void *)-1;
}

添加系统调用处理函数:

int sys_brk(void *addr) {
return 0;
}

加一个 case:

case SYS_brk:
ret = sys_brk((void *)c->GPR2);
Log("sys_brk(%p, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;

此时,hello 测试程序中的 printf() 将格式化完毕的字符串通过一次 write() 系统调用进行输出。可以观察 strace:

[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,43,do_syscall] sys_write(1, 0x8300567c, 13) = 13
[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,47,do_syscall] sys_brk(0x83006cf0, 0, 0) = 0
[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,47,do_syscall] sys_brk(0x83007000, 0, 0) = 0
[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,43,do_syscall] sys_write(1, 0x830068e0, 45) = 45
...

缓冲区是批处理技术的核心,libc 中的 fread()fwrite() 正是通过缓冲区来将数据累积起来,然后再通过一次系统调用进行处理。

这篇文章测试了 write() 系统调用的开销。

fwrite() 的实现中有缓冲区,printf() 打印的字符不一定会马上通过 write() 系统调用输出,但遇到 \n 时可以强行将缓冲区中的内容进行输出。

事实上,我们平时使用的 printf(), cout 这些库函数和库类,对字符串进行格式化之后,最终也是通过系统调用进行输出。这些都是系统调用封装成库函数的例子。系统调用本身对操作系统的各种资源进行了抽象,但为了给上层的程序员提供更好的接口,库函数会再次对部分系统调用再次进行抽象。另一方面,系统调用依赖于具体的操作系统,因此库函数的封装也提高了程序的可移植性

我们目前并没有严格按照 AM 的 API 来将相应的系统调用功能暴露给用户程序,毕竟与 AM 相比,对操作系统上运行的程序来说,libc 的接口更加广为人们所用,我们也就不必班门弄斧了。

也就是说 libc 的实现中使用了我们编写的系统调用功能

How Hello 1

我们知道 navy-apps/tests/hello/hello.c 只是一个 C 源文件,它会被编译链接成一个 ELF 文件。那么,hello 程序一开始在哪里?它是怎么出现内存中的?为什么会出现在目前的内存位置?它的第一条指令在哪里?究竟是怎么执行到它的第一条指令的?hello 程序在不断地打印字符串,每一个字符又是经历了什么才会最终出现在终端上?

可以扣除 C 库中 printf()write() 转换的部分。

如果我们想了解 C 库中 printf()write() 的过程,ftrace 将是一个很好的工具。

但我们知道,Nanos-lite 和它加载的用户程序是两个独立的 ELF 文件,这意味着,如果我们给 NEMU 的 ftrace 指定其中一方的 ELF 文件,那么 ftrace 就无法正确将另一方的地址翻译成正确的函数名。

事实上,我们可以让 NEMU 的 ftrace 支持多个 ELF,让 ftrace 同时追踪 Nanos-lite 和用户程序的函数调用。

TODO

前几个问题可以参考 加载第一个用户程序 部分。

主要关注 每一个字符又是经历了什么才会最终出现在终端上 这个问题:

简易文件系统

原理

要实现一个完整的批处理系统,我们还需要向系统提供多个程序。

操作系统还需要在存储介质的驱动程序之上为用户程序提供一种更高级的抽象,那就是文件。

在这里,我们先讨论普通意义上的文件。这样,那些额外的属性就维护了文件到 ramdisk 存储位置的映射。为了管理这些映射,同时向上层提供文件操作的接口,我们需要在 Nanos-lite 中实现一个文件系统。

我们可以定义一个简易文件系统 sfs (Simple File System):

既然文件的数量和大小都是固定的,我们自然可以把每一个文件分别固定在 ramdisk 中的某一个位置。

为了记录 ramdisk 中各个文件的名字和大小,我们还需要一张文件记录表。Nanos-lite 的 Makefile 已经提供了维护这些信息的脚本:

update:
$(MAKE) -s -C $(NAVY_HOME) ISA=$(ISA) ramdisk
@ln -sf $(NAVY_HOME)/build/ramdisk.img $(RAMDISK_FILE)
@ln -sf $(NAVY_HOME)/build/ramdisk.h src/files.h
@ln -sf $(NAVY_HOME)/libs/libos/src/syscall.h src/syscall.h

然后运行 make ARCH=riscv32-nemu update 就会自动编译 Navy 中的程序,并把 navy-apps/fsimg/ 目录下的所有内容整合成 ramdisk 镜像 navy-apps/build/ramdisk.img,同时生成这个 ramdisk 镜像的文件记录表 navy-apps/build/ramdisk.h

如果希望在镜像中添加一个应用程序,需要把它加到 navy-apps/MakefileTESTS 变量中。

文件记录表其实是一个数组,数组的每个元素都是一个结构体:

typedef struct {
char *name; // 文件名
size_t size; // 文件大小
size_t disk_offset; // 文件在 ramdisk 中的偏移
} Finfo;

一些注记:

为了方便用户程序进行标准输入输出,操作系统准备了三个默认的文件描述符:

#define FD_STDIN 0
#define FD_STDOUT 1
#define FD_STDERR 2

它们分别对应标准输入 stdin,标准输出 stdout 和标准错误 stderr

根据以上信息,我们就可以在文件系统中实现以下的文件操作了:

int fs_open(const char *pathname, int flags, int mode);
size_t fs_read(int fd, void *buf, size_t len);
size_t fs_write(int fd, const void *buf, size_t len);
size_t fs_lseek(int fd, size_t offset, int whence);
int fs_close(int fd);

这些文件操作实际上是相应的系统调用在内核中的实现。你可以通过 man 查阅它们的功能,例如:

man 2 open

其中 2 表示查阅和系统调用相关的 manual page。

软件层

我们需要修改 nanos-lite/src/fs.c,首先定义文件描述符和偏移量属性 open_offset 的结构,并通过 get_open_file_index 获取给定文件描述符在 open_file_table 结构数组中的下标:

typedef struct {
size_t fd;
size_t open_offset; // relative to disk_offset
} OFinfo;
static OFinfo open_file_table[LENGTH(file_table)];
static size_t open_file_table_index = 0;
static int get_open_file_index(int fd) { // return -1 on failure
int target_index = -1;
for (int i = 0; i < open_file_table_index; ++i) {
if (open_file_table[i].fd == fd) {
target_index = i;
break;
}
}
return target_index;
}

在此基础上,我们编写如下函数。

int fs_open(const char *pathname, int flags, int mode) {
for (int i = 0; i < LENGTH(file_table); ++i) {
if (strcmp(file_table[i].name, pathname) == 0) {
if (i <= 2) {
Log("ignore open %s", pathname);
return i;
}
open_file_table[open_file_table_index].fd = i;
open_file_table[open_file_table_index].open_offset = 0;
open_file_table_index++;
return i;
}
}
panic("file %s not found", pathname);
while (1);
}

对文件不存在的情况需要直接 panic,与 manual 不一致。

忽略 flagsmode 参数。

忽略对 stdin, stdoutstderr 这三个特殊文件的打开操作。

size_t fs_read(int fd, void *buf, size_t len) {
if (fd <= 2) {
Log("ignore read %s", file_table[fd].name);
return 0;
}
int target_index = get_open_file_index(fd);
if (target_index == -1) {
Log("file %s not open before read", file_table[fd].name);
return -1;
}
size_t read_len = len;
size_t open_offset = open_file_table[target_index].open_offset;
size_t size = file_table[fd].size;
size_t disk_offset = file_table[fd].disk_offset;
if (open_offset > size) return 0;
if (open_offset + len > size) read_len = size - open_offset;
ramdisk_read(buf, disk_offset + open_offset, read_len);
open_file_table[target_index].open_offset += read_len;
return read_len;
}

同样忽略对 stdin, stdoutstderr 这三个特殊文件的打开操作。

注意偏移量不要越过文件的边界。

最终调用了 ramdisk_read 实现读取操作。

size_t fs_write(int fd, const void *buf, size_t len) {
if (fd == 0) {
Log("ignore write %s", file_table[fd].name);
return 0;
}
if (fd == 1 || fd == 2) {
for (size_t i = 0; i < len; ++i)
putch(*((char *)buf + i));
return len;
}
int target_index = get_open_file_index(fd);
if (target_index == -1) {
Log("file %s not open before write", file_table[fd].name);
return -1;
}
size_t write_len = len;
size_t open_offset = open_file_table[target_index].open_offset;
size_t size = file_table[fd].size;
size_t disk_offset = file_table[fd].disk_offset;
if (open_offset > size) return 0;
if (open_offset + len > size) write_len = size - open_offset;
ramdisk_write(buf, disk_offset + open_offset, write_len);
open_file_table[target_index].open_offset += write_len;
return write_len;
}

注意该函数取代了之前的系统调用处理函数 sys_write

若写入 stdoutstderr,则用 putch() 输出到串口。

size_t fs_lseek(int fd, size_t offset, int whence) {
if (fd <= 2) {
Log("ignore lseek %s", file_table[fd].name);
return 0;
}
int target_index = get_open_file_index(fd);
if (target_index == -1) {
Log("file %s not open before lseek", file_table[fd].name);
return -1;
}
size_t new_offset = -1;
size_t size = file_table[fd].size;
size_t open_offset = open_file_table[target_index].open_offset;
switch (whence) {
case SEEK_SET:
if (offset > size) new_offset = size;
new_offset = offset;
break;
case SEEK_CUR:
if (offset + open_offset > size) new_offset = size;
new_offset = offset + open_offset;
break;
case SEEK_END:
if (offset + size > size) new_offset = size;
new_offset = offset + size;
break;
default:
Log("Unknown whence %d", whence);
return -1;
}
assert(new_offset >= 0);
open_file_table[target_index].open_offset = new_offset;
return new_offset;
}

其中 whence 的枚举值定义在 fs.h 中。

STFM

int fs_close(int fd) {
if (fd <= 2) {
Log("ignore close %s", file_table[fd].name);
return 0;
}
int target_index = get_open_file_index(fd);
if (target_index >= 0) {
for (int i = target_index; i < open_file_table_index - 1; ++i) {
open_file_table[i] = open_file_table[i + 1];
}
open_file_table_index--;
assert(open_file_table_index >= 0);
return 0;
}
Log("file %s not open before close", file_table[fd].name);
return -1;
}

需要更新 open_file_table

下面需要修改系统调用事件分发:

case SYS_open:
ret = fs_open((const char *)c->GPR2, c->GPR3, c->GPR4);
Log("fs_open(%s, %d, %d) = %d",(const char *)c->GPR2, c->GPR3, c->GPR4, ret);
break;
case SYS_read:
ret = fs_read(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4);
Log("fs_read(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;
case SYS_close:
ret = fs_close(c->GPR2);
Log("fs_close(%d, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;
case SYS_write:
ret = fs_write(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4);
Log("fs_write(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;
case SYS_lseek:
ret = fs_lseek(c->GPR2, (size_t)c->GPR3, c->GPR4);
Log("fs_lseek(%d, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;

最后,我们之前是让 loader 来直接调用 ramdisk_read() 来加载用户程序。ramdisk 中的文件数量增加之后,这种方式就不合适了,我们需要让 loader 享受到文件系统的便利。

为此,我们需要利用 naive_uload() 函数的 filename 参数,并将 loader 函数中的文件读取操作修改为如下范式:

int fd = fs_open(filename, 0, 0);
assert(fd >= 2); // ignore stdin, stdout, stderr
assert(fs_lseek(fd, offset, SEEK_SET) >= 0);
assert(fs_read(fd, buf, len) >= 0);
...
assert(fs_close(fd) == 0);

以后更换用户程序只需要修改传入 naive_uload() 函数的文件名即可:

// load program here
const char filename[] = "/bin/file-test";
naive_uload(NULL, filename);

需要定义一个变量,直接传参有时候会无法识别全文件名……

用户层

添加相应的系统调用即可:

int _open(const char *path, int flags, mode_t mode) {
return _syscall_(SYS_open, (intptr_t)path, flags, mode);
}
int _read(int fd, void *buf, size_t count) {
return _syscall_(SYS_read, fd, (intptr_t)buf, count);
}
int _close(int fd) {
return _syscall_(SYS_close, fd, 0, 0);
}
int _write(int fd, void *buf, size_t count) {
return _syscall_(SYS_write, fd, (intptr_t)buf, count);
}
off_t _lseek(int fd, off_t offset, int whence) {
return _syscall_(SYS_lseek, fd, offset, whence);
}

测试程序

file-test 提供了对文件操作相关的测试:

int main() {
FILE *fp = fopen("/share/files/num", "r+");
assert(fp);
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
assert(size == 5000);
fseek(fp, 500 * 5, SEEK_SET);
int i, n;
for (i = 500; i < 1000; i ++) {
fscanf(fp, "%d", &n);
assert(n == i + 1);
}
fseek(fp, 0, SEEK_SET);
for (i = 0; i < 500; i ++) {
fprintf(fp, "%4d\n", i + 1 + 1000);
}
for (i = 500; i < 1000; i ++) {
fscanf(fp, "%d", &n);
assert(n == i + 1);
}
fseek(fp, 0, SEEK_SET);
for (i = 0; i < 500; i ++) {
fscanf(fp, "%d", &n);
assert(n == i + 1 + 1000);
}
fclose(fp);
printf("PASS!!!\n");
return 0;
}

运行后,其系统调用如下:

sys_brk(0x83009c10, 0, 0) = 0
sys_brk(0x8300a000, 0, 0) = 0
fs_open(/share/files/num, 2, 438) = 22
sys_brk(0x8300b000, 0, 0) = 0
fs_lseek(22, 0, 2) = 5000
fs_lseek(22, 2500, 0) = 2500
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 452
fs_lseek(22, 4999, 0) = 4999
fs_lseek(22, 0, 0) = 0
fs_write(22, 0x83009c08, 1024) = 1024
fs_write(22, 0x83009c08, 1024) = 1024
fs_write(22, 0x83009c08, 452) = 452
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 452
fs_lseek(22, 0, 1) = 5000
fs_lseek(22, 4999, 0) = 4999
fs_lseek(22, 0, 0) = 0
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 1024
fs_read(22, 0x83009c08, 1024) = 1024
fs_lseek(22, 2499, 0) = 2499
fs_close(22, 0, 0) = 0
fs_write(1, 0x83009c08, 8) = 8
fs_close(0, 0, 0) = 0
fs_close(1, 0, 0) = 0
fs_close(2, 0, 0) = 0
sys_exit(0, 0, 0)

观察到如下现象:

可以把 strace 中的文件描述符直接翻译成文件名,得到可读性更好的 trace 信息:

char* get_file_name(int fd) {
return file_table[fd].name;
}

然后在 syscall.c 中调用 get_file_name 就可以啦。

一些用户程序库函数的调用轨迹:

fopen -> _fopen_r -> _open_r -> _open
fseek -> _fseeko_r -> _ftello_r -> ...
-> _fflush_r -> ...
... -> __sseek -> _lseek_r -> _lseek
ftell -> _ftello_r -> ...
fscanf -> _vfscanf_r -> __svfscanf_r -> __srefill_r
-> _fread_r -> memcpy -> ...
fprintf -> _vfprintf_r -> __sfputs_r -> _fputc_r -> _putc_r -> ...
fclose -> ...

很怪的调用轨迹,中间有些链条找不到……

一切皆文件

想法

在 Nanos-lite 上,如果用户程序想访问设备,要怎么办呢?

我们需要有一种方式对设备的功能进行抽象,向用户程序提供统一的接口。

注意到文件的本质就是字节序列,而计算机系统中到处都是字节序列:

自然地,我们有了 Everything is a file 的想法,我们可以使用文件的接口来操作计算机上的一切,而不必对它们进行详细的区分。

#include "/dev/urandom"

会将 urandom 设备中的内容包含到源文件中:由于 urandom 设备是一个长度无穷的字节序列,提交一个包含上述内容的程序源文件将会令一些检测功能不强的 Online Judge 平台直接崩溃……

这其实体现了 Unix 哲学的部分内容:每个程序采用文本文件作为输入输出,这样可以使程序之间易于合作。

为了向用户程序提供统一的抽象,Nanos-lite 也尝试将 IOE 抽象成文件。

虚拟文件系统

我们对之前实现的文件操作 API 的语义进行扩展,让它们可以支持任意文件:

int fs_open(const char *pathname, int flags, int mode);
size_t fs_read(int fd, void *buf, size_t len);
size_t fs_write(int fd, const void *buf, size_t len);
size_t fs_lseek(int fd, size_t offset, int whence);
int fs_close(int fd);

这组扩展语义之后的 API 叫 VFS(虚拟文件系统)

而真实文件系统是指具体如何操作某一类文件,比如在 Nanos-lite 上,普通文件通过 ramdisk 的 API 进行操作。

一些真实文件系统:

  • Windows
    • 普通文件 -> NTFS
  • GNU/Linux
    • 普通文件 -> EXT4
    • 特殊文件 -> procfs, tmpfs, devfs, sysfs, initramfs

所以,VFS 其实是对不同种类的真实文件系统的抽象,它用一组 API 来描述了这些真实文件系统的抽象行为,屏蔽了真实文件系统之间的差异。

在 Nanos-lite 中,实现 VFS 的关键就是 Finfo 结构体中的两个读写函数指针:

typedef struct {
char *name; // 文件名
size_t size; // 文件大小
size_t disk_offset; // 文件在 ramdisk 中的偏移
ReadFn read; // 读函数指针
WriteFn write; // 写函数指针
} Finfo;

我们约定,当上述的函数指针为 NULL 时,表示相应文件是一个普通文件,通过 ramdisk 的 API 来进行文件的读写。

VFS 的实现展示了如何用 C 语言来模拟面向对象编程的一些基本概念:例如通过结构体来实现类的定义,结构体中的普通变量可以看作类的成员,函数指针就可以看作类的方法,给函数指针设置不同的函数可以实现方法的重载…

Object-Oriented Programming With ANSI-C 这本书专门介绍了如何用 ANSI-C 来模拟 OOP 的各种概念和功能。

我们可以对字节序列进行分类:

真实的操作系统还会对 lseek 操作进行抽象,我们在 Nanos-lite 中进行了简化,就不实现这一抽象了。

操作系统之上的 IOE

串口

我们只需要在 nanos-lite/src/device.c 中实现 serial_write()

size_t serial_write(const void *buf, size_t offset, size_t len) {
for (size_t i = 0; i < len; ++i) putch(*((char *)buf + i));
return len;
}

然后在文件记录表中设置相应的写函数即可:

static Finfo file_table[] __attribute__((used)) = {
[FD_STDIN] = {"stdin", 0, 0, invalid_read, invalid_write},
[FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write},
[FD_STDERR] = {"stderr", 0, 0, invalid_read, serial_write},
#include "files.h"
};

相应的修改 fs_readfs_write

size_t fs_read(int fd, void *buf, size_t len) {
ReadFn readFn = file_table[fd].read;
if (readFn != NULL) {
// TODO: prepare parameters
return readFn(buf, 0, len);
}
...
}
size_t fs_write(int fd, const void *buf, size_t len) {
WriteFn writeFn = file_table[fd].write;
if (writeFn != NULL) {
// TODO: prepare parameters
return writeFn(buf, 0, len);
}
...
}

不需要对 fd 进行特判了。

由于串口是一个字符设备,offset 参数可以忽略。

时钟

时钟比较特殊,大部分操作系统并没有把它抽象成一个文件,而是直接提供一些和时钟相关的系统调用来给用户程序访问。在 Nanos-lite 中,我们也提供一个 SYS_gettimeofday 系统调用,用户程序可以通过它读出当前的系统时间。

实现如下。首先是软件层,参考 man 2 gettimeofday 先定义一些数据结构:

#define time_t uint64_t
#define suseconds_t uint64_t
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
struct timezone {
int tz_minuteswest; /* minutes west of Greenwich */
int tz_dsttime; /* type of DST correction */
};
int sys_gettimeofday(struct timeval *tv, struct timezone *tz) {
uint64_t us = io_read(AM_TIMER_UPTIME).us;
tv->tv_sec = us / 1000000;
tv->tv_usec = us - us / 1000000 * 1000000;
return 0;
}

我们使用了 io_read 访问设备抽象寄存器。注意这里 sys_gettimeofday 并不处理参数 tz

The use of the timezone structure is obsolete; the tz argument should normally be specified as NULL.

obsolete -> 弃用

然后是事件分发:

case SYS_gettimeofday:
ret = sys_gettimeofday((struct timeval *)c->GPR2, (struct timezone *)c->GPR3);
Log("sys_gettimeofday(%p, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret);
break;

下面是用户层:

int _gettimeofday(struct timeval *tv, struct timezone *tz) {
return _syscall_(SYS_gettimeofday, (intptr_t)tv, (intptr_t)tz, 0);
}

我们写一个测试程序 timer-test

#include <stdio.h>
#include <assert.h>
#include <sys/time.h>
int main() {
struct timeval init;
struct timeval now;
assert(gettimeofday(&init, NULL) == 0);
time_t init_sec = init.tv_sec;
suseconds_t init_usec = init.tv_usec;
size_t times = 1;
while (1) {
assert(gettimeofday(&now, NULL) == 0);
time_t now_sec = now.tv_sec;
suseconds_t now_usec = now.tv_usec;
uint64_t time_gap = (now_sec - init_sec) * 1000000 + (now_usec - init_usec); // unit: us
if (time_gap > 500000 * times) {
printf("Half a second passed, %u time(s)\n", times);
times++;
}
}
}

每过 0.5 秒输出一句话。注意这里不要出现浮点操作,否则就需要让 NEMU 实现浮点指令了……

NDL

为了更好地封装 IOE 的功能,我们在 Navy 中提供了一个叫 NDL (NJU DirectMedia Layer) 的多媒体库。这个库的代码位于 navy-apps/libs/libndl/NDL.c 中。

NDL 向用户提供了一个和时钟相关的 API:

// 以毫秒为单位返回系统时间
uint32_t NDL_GetTicks();

你需要用 gettimeofday() 实现 NDL_GetTicks(),然后修改 timer-test 测试,让它通过调用 NDL_GetTicks() 来获取当前时间。

我们约定程序在使用 NDL 库的功能之前必须先调用 NDL_Init()

实现如下:

#include <sys/time.h>
#include <assert.h>
uint32_t NDL_GetTicks() {
struct timeval tv;
assert(gettimeofday(&tv, NULL) == 0);
return tv.tv_sec * 1000 + tv.tv_usec / 1000;
}

修改 timer-test 测试,添加宏 HAS_NDL 进行测试控制:

#include <stdio.h>
#define HAS_NDL
#ifndef HAS_NDL
#include <assert.h>
#include <sys/time.h>
void gettimeofday_test() {
printf("gettimeofday test start...\n");
struct timeval init;
struct timeval now;
assert(gettimeofday(&init, NULL) == 0);
time_t init_sec = init.tv_sec;
suseconds_t init_usec = init.tv_usec;
size_t times = 1;
while (1) {
assert(gettimeofday(&now, NULL) == 0);
time_t now_sec = now.tv_sec;
suseconds_t now_usec = now.tv_usec;
uint64_t time_gap = (now_sec - init_sec) * 1000000 + (now_usec - init_usec); // unit: us
if (time_gap > 500000 * times) {
printf("Half a second passed, %u time(s)\n", times);
times++;
}
}
}
#else
#include <NDL.h>
void NDL_GetTicks_test() {
NDL_Init(0);
printf("NDL_GetTicks test start...\n");
uint32_t init = NDL_GetTicks();
size_t times = 1;
while (1) {
uint32_t now = NDL_GetTicks();
uint32_t time_gap = now - init;
if (time_gap > 500 * times) {
printf("Half a second passed, %u time(s)\n", times);
times++;
}
}
}
#endif
int main() {
#ifndef HAS_NDL
gettimeofday_test();
#else
NDL_GetTicks_test();
#endif

注意需要修改对应的 Makefile 文件:

NAME = timer-test
SRCS = main.c
#ifdef HAS_NDL
LIBS = libndl
#endif
include $(NAVY_HOME)/Makefile

键盘

按键信息对系统来说本质上就是到来了一个事件。一种简单的方式是把事件以文本的形式表现出来,我们定义以下两种事件:

按键名称与 AM 中的定义的按键名相同,均为大写。此外,一个事件以换行符 \n 结束。

Nanos-lite 和 Navy 约定,上述事件抽象成一个特殊文件 /dev/events,它需要支持读操作,用户程序可以从中读出按键事件,但它不必支持 lseek,因为它是一个字符设备。

我们可以假设一次最多只会读出一个事件

软件层的实现如下,首先需要实现对特殊文件 /dev/events 的读操作,在 nanos-lite/src/device.c 中:

#define TEMP_BUF_SIZE 32
static char temp_buf[TEMP_BUF_SIZE]; // for events_read
size_t events_read(void *buf, size_t offset, size_t len) {
memset(temp_buf, 0, TEMP_BUF_SIZE); // reset
AM_INPUT_KEYBRD_T ev = io_read(AM_INPUT_KEYBRD);
if (ev.keycode == AM_KEY_NONE) return 0;
const char *name = keyname[ev.keycode];
int ret = ev.keydown ? sprintf(temp_buf, "kd %s\n", name) : sprintf(temp_buf, "ku %s\n", name);
// ret -> exclude terminating null character
if (ret >= len) {
strncpy(buf, temp_buf, len - 1);
ret = len;
} else {
strncpy(buf, temp_buf, ret);
}
return ret; // ret -> include terminating null character
}

把事件写入到 buf 中,最长写入 len 字节,然后返回写入的实际长度。

这里本来应该使用 snprintf,但是 klib 中还没有实现……所以使用了 temp_buf 作为中转

需要小心各种参数和返回值中是否包含 terminating null character

修改 file_table

enum {FD_STDIN, FD_STDOUT, FD_STDERR, DEV_EVENTS, FD_FB};
static Finfo file_table[] __attribute__((used)) = {
[FD_STDIN] = {"stdin", 0, 0, invalid_read, invalid_write},
[FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write},
[FD_STDERR] = {"stderr", 0, 0, invalid_read, serial_write},
[DEV_EVENTS] = {"/dev/events", 0, 0, events_read, invalid_write},
#include "files.h"
};

注意 FD_FB 可以用于对特殊文件的特判上,如:

int fs_open(const char *pathname, int flags, int mode) {
for (int i = 0; i < LENGTH(file_table); ++i) {
if (strcmp(file_table[i].name, pathname) == 0) {
if (i < FD_FB) {
Log("ignore open %s", pathname);
return i;
}
open_file_table[open_file_table_index].fd = i;
open_file_table[open_file_table_index].open_offset = 0;
open_file_table_index++;
return i;
}
}
panic("file %s not found", pathname);
while (1);
}

然后是用户层,我们使用 NDL 封装 IOE 的功能:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int NDL_PollEvent(char *buf, int len) {
int fd = open("/dev/events", 0, 0);
int ret = read(fd, buf, len);
assert(close(fd) == 0);
return ret == 0 ? 0 : 1;
}

注意区分 openfopen,前者是低级 IO,后者是高级 IO

最后是测试程序:

#include <stdio.h>
#include <NDL.h>
int main() {
NDL_Init(0);
while (1) {
char buf[64];
if (NDL_PollEvent(buf, sizeof(buf))) {
printf("receive event: %s\n", buf);
}
}
return 0;
}

对应的系统调用如下:

fs_open(/dev/events, 0, 0) = 3
fs_read(/dev/events, 0x800a6f50, 64) = 0 -> miss
fs_close(/dev/events, 0, 0) = 0
fs_open(/dev/events, 0, 0) = 3
fs_read(/dev/events, 0x800a6f50, 64) = 5 -> hit
fs_close(/dev/events, 0, 0) = 0
sys_brk(0x83008cf0, 0, 0) = 0
sys_brk(0x83009000, 0, 0) = 0
fs_write(stdout, 0x830088e0, 20) = 20 -> receive event: kd Z
fs_write(stdout, 0x830088e0, 1) = 1 -> \n
fs_open(/dev/events, 0, 0) = 3
fs_read(/dev/events, 0x800a6f50, 64) = 5 -> hit
fs_close(/dev/events, 0, 0) = 0
fs_write(stdout, 0x830088e0, 20) = 20 -> receive event: ku Z
fs_write(stdout, 0x830088e0, 1) = 1 -> \n
...

VGA

程序为了更新屏幕,只需要将像素信息写入 VGA 的显存即可。

Nanos-lite 和 Navy 约定,把显存抽象成文件 /dev/fb,它需要支持写操作和 lseek,以便于把像素更新到屏幕的指定位置上。

NDL 向用户提供了两个和绘制屏幕相关的 API:

// 打开一张 (*w) X (*h) 的画布
// 如果 *w 和 *h 均为 0,则将系统全屏幕作为画布,并将 *w 和 *h 分别设为系统屏幕的大小
void NDL_OpenCanvas(int *w, int *h);
// 向画布 `(x, y)` 坐标处绘制 `w*h` 的矩形图像,并将该绘制区域同步到屏幕上
// 图像像素按行优先方式存储在 `pixels` 中,每个像素用 32 位整数以 `00RRGGBB` 的方式描述颜色
void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h);

其中画布是一个面向程序的概念,程序绘图时的坐标都是针对画布来设定的,这样程序就无需关心系统屏幕的大小,以及需要将图像绘制到系统屏幕的哪一个位置。NDL 可以根据系统屏幕大小以及画布大小,来决定将画布贴到哪里。

Nanos-lite 和 Navy 约定,屏幕大小的信息通过 /proc/dispinfo 文件来获得,它需要支持读操作。navy-apps/README.md 中对这个文件内容的格式进行了约定:

procfs 文件系统:所有的文件都是 key-value pair,格式为 [key] : [value], 冒号左右可以有任意多 (0 个或多个) 的空白字符 (whitespace).

  • /proc/dispinfo: 屏幕信息,包含的 keys: WIDTH 表示宽度,HEIGHT 表示高度。
  • /proc/cpuinfo (可选): CPU 信息。
  • /proc/meminfo (可选): 内存信息。

例如一个合法的 /proc/dispinfo 文件例子如下:

WIDTH : 640 HEIGHT : 480

下面是具体实现。

/proc/dispinfo

首先是软件层,实现对其的读操作,类似 events_read

size_t dispinfo_read(void *buf, size_t offset, size_t len) {
memset(temp_buf, 0, TEMP_BUF_SIZE); // reset
AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
int width = ev.width;
int height = ev.height;
int ret = sprintf(temp_buf, "WIDTH : %d\nHEIGHT : %d", width, height);
// ret -> exclude terminating null character
if (ret >= len) {
strncpy(buf, temp_buf, len - 1);
ret = len;
} else {
strncpy(buf, temp_buf, ret);
}
return ret; // ret -> include terminating null character
}

这里 /proc/dispinfo 文件的格式只要满足上面的要求就行了,并不唯一。

file_table 中添加:

[PROC_DISPINFO] = {"/proc/dispinfo", 0, 0, dispinfo_read, invalid_write},

由于是字符设备,位于 FD_FB 之上。

下面是用户层,我们添加 init_dispinfo 函数用于解析 /proc/dispinfo 文件的内容,并写入 screen_wscreen_h,作为屏幕大小:

static void init_dispinfo() {
int buf_size = 1024; // TODO: may be insufficient
char * buf = (char *)malloc(buf_size * sizeof(char));
int fd = open("/proc/dispinfo", 0, 0);
int ret = read(fd, buf, buf_size);
assert(ret < buf_size); // to be cautious...
assert(close(fd) == 0);
int i = 0;
int width = 0, height = 0;
assert(strncmp(buf + i, "WIDTH", 5) == 0);
i += 5;
for (; i < buf_size; ++i) {
if (buf[i] == ':') { i++; break; }
assert(buf[i] == ' ');
}
for (; i < buf_size; ++i) {
if (buf[i] >= '0' && buf[i] <= '9') break;
assert(buf[i] == ' ');
}
for (; i < buf_size; ++i) {
if (buf[i] >= '0' && buf[i] <= '9') {
width = width * 10 + buf[i] - '0';
} else {
break;
}
}
assert(buf[i++] == '\n');
assert(strncmp(buf + i, "HEIGHT", 6) == 0);
i += 6;
for (; i < buf_size; ++i) {
if (buf[i] == ':') { i++; break; }
assert(buf[i] == ' ');
}
for (; i < buf_size; ++i) {
if (buf[i] >= '0' && buf[i] <= '9') break;
assert(buf[i] == ' ');
}
for (; i < buf_size; ++i) {
if (buf[i] >= '0' && buf[i] <= '9') {
height = height * 10 + buf[i] - '0';
} else {
break;
}
}
free(buf);
screen_w = width;
screen_h = height;
}

NDL_Init 中调用它即可。

我们还需要记录画布的大小,修改 NDL_OpenCanvas

if (*w == 0 && *h == 0) {
*w = screen_w;
*h = screen_h;
}
canvas_w = *w;
canvas_h = *h;
canvas_relative_screen_w = (screen_w - canvas_w) / 2;
canvas_relative_screen_h = (screen_h - canvas_h) / 2;
assert(canvas_w + canvas_relative_screen_w <= screen_w
&& canvas_h + canvas_relative_screen_h <= screen_h);
}

这里 canvas_relative_screen_wcanvas_relative_screen_h 是画布相对于屏幕左上角的坐标,我们将画布居中

/dev/fb

首先是软件层,添加 fb_write

size_t fb_write(const void *buf, size_t offset, size_t len) {
AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
int width = ev.width;
int y = offset / width;
int x = offset - y * width;
io_write(AM_GPU_FBDRAW, x, y, (void *)buf, len, 1, true);
return len;
}

得到硬件层的屏幕大小后,对 offset 进行处理,得到正确的坐标。

这里假定 buf 中仅包含了一行的像素信息。

隔行似乎难以处理,对用户层而言……

file_table 中添加:

[FD_FB] = {"/dev/fb", 0, 0, invalid_read, fb_write},

我们还需要修改 fs_readfs_write

size_t fs_write(int fd, const void *buf, size_t len) {
WriteFn writeFn = file_table[fd].write;
if (writeFn != NULL && fd < FD_FB) {
// ignore offset
return writeFn(buf, 0, len);
}
...
writeFn ?
writeFn (buf, disk_offset + open_offset, write_len):
ramdisk_write(buf, disk_offset + open_offset, write_len);
open_file_table[target_index].open_offset += write_len;
return write_len;
}

另外在 init_fs() 中对文件记录表中 /dev/fb 的大小进行初始化:

void init_fs() {
// TODO: initialize the size of /dev/fb
AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
int width = ev.width;
int height = ev.height;
file_table[FD_FB].size = width * height * 4;
}

这里会有一个问题,FD_FB 之后的文件在 ramdisk.img 中的偏移会出错。

若不修改,ramdisk.img 中前 /dev/fb 大小的字节会被覆盖。

可能的修复方案,修改 navy-apps/Makefileramdisk 规则,让每个文件的偏移显式加上 /dev/fb 的大小:

RAMDISK = build/ramdisk.img
RAMDISK_H = build/ramdisk.h
$(RAMDISK): fsimg
$(eval FSIMG_FILES := $(shell find -L ./fsimg -type f))
@mkdir -p $(@D)
@cat $(FSIMG_FILES) > $@
@truncate -s \%512 $@
@echo "// file path, file size, offset in disk" > $(RAMDISK_H)
@wc -c $(FSIMG_FILES) | grep -v 'total$$' | sed -e 's+ ./fsimg+ +' | awk -v sum=480000 '{print "\x7b\x22" $$2 "\x22\x2c " $$1 "\x2c " sum "\x7d\x2c";sum += $$1}' >> $(RAMDISK_H)

sum=0sum=480000

同时修改 nanos-lite/src/ramdisk.c

#define RAMDISK_SIZE ((&ramdisk_end) - (&ramdisk_start) + 480000)

此时 ramdisk_end 所指示的位置失效……

还是不行……

注意到 ramdisk.imgRAMDISK_SIZE 个字节是固定的,所以我们需要将 FD_FB 放到最后……

然而似乎 resources.S 中已经固定了可以使用的空间为 ramdisk.img,上面的方案屏幕无显示……

但是目前未观察到问题……

怪了……

下面是用户层,我们实现 NDL_DrawRect

void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) {
int fd = open("/dev/fb", 0, 0);
for (int i = 0; i < h && y + i < canvas_h; ++i) {
lseek(fd, (y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w), SEEK_SET);
write(fd, pixels + i * w, w < canvas_w - x ? w : canvas_w - x);
}
assert(close(fd) == 0);
}

建议画图梳理清楚系统屏幕,即 frame buffer,NDL_OpenCanvas() 打开的画布,以及 NDL_DrawRect() 指示的绘制区域之间的位置关系。

这里也是逐行写入 /dev/fb 的,对应之前 fb_write 的实现。

测试程序为 navy-apps/tests/bmp-test

#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <NDL.h>
#include <BMP.h>
int main() {
NDL_Init(0);
int w, h;
void *bmp = BMP_Load("/share/pictures/projectn.bmp", &w, &h);
// printf("width: %d, height: %d\n", w, h);
assert(bmp);
NDL_OpenCanvas(&w, &h);
// printf("width: %d, height: %d\n", w, h);
NDL_DrawRect(bmp, 0, 0, w, h);
free(bmp);
NDL_Quit();
printf("Test ends! Spinning...\n");
while (1);
return 0;
}

可以得到图片的大小为 128×128,屏幕的大小为 400×300

需要注意画布的大小不能超过图片的大小,否则图片会显示错误,原因详见 NDL_DrawRect 的实现。

下面是程序执行中的系统调用:

ramdisk info: start = 0x8000423d, end = 0x8009de3d, size = 629760 bytes
sys_brk(0x8300ad20, 0, 0) = 0
sys_brk(0x8300b000, 0, 0) = 0
fs_open(/proc/dispinfo, 0, 0) = 4
fs_read(/proc/dispinfo, 0x8300a910, 1024) = 24
fs_close(/proc/dispinfo, 0, 0) = 0
fs_open(/share/pictures/projectn.bmp, 0, 438) = 29
fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024
sys_brk(0x8301c000, 0, 0) = 0
fs_lseek(/share/pictures/projectn.bmp, 0, 1) = 1024
fs_lseek(/share/pictures/projectn.bmp, 54, 0) = 54
fs_lseek(/share/pictures/projectn.bmp, 48906, 0) = 48906
fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 384
fs_lseek(/share/pictures/projectn.bmp, 48522, 0) = 48522
fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 768
fs_lseek(/share/pictures/projectn.bmp, 48906, 0) = 48906
fs_lseek(/share/pictures/projectn.bmp, 48138, 0) = 48138
fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024
fs_lseek(/share/pictures/projectn.bmp, 48522, 0) = 48522
fs_lseek(/share/pictures/projectn.bmp, 47754, 0) = 47754
fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024
...
fs_close(/share/pictures/projectn.bmp, 0, 0) = 0
fs_open(/dev/fb, 0, 0) = 5
fs_lseek(/dev/fb, 34536, 0) = 34536
fs_write(/dev/fb, 0x8300aec8, 128) = 128
fs_lseek(/dev/fb, 34936, 0) = 34936
fs_write(/dev/fb, 0x8300b0c8, 128) = 128
fs_lseek(/dev/fb, 35336, 0) = 35336
fs_write(/dev/fb, 0x8300b2c8, 128) = 128
fs_lseek(/dev/fb, 35736, 0) = 35736
fs_write(/dev/fb, 0x8300b4c8, 128) = 128
...
fs_lseek(/dev/fb, 85336, 0) = 85336
fs_write(/dev/fb, 0x8301acc8, 128) = 128
fs_close(/dev/fb, 0, 0) = 0
fs_write(stdout, 0x8300aac0, 23) = 23
^C

更丰富的运行时环境

多媒体库

在 Linux 中,有一批 GUI 程序是使用 SDL 库来开发的。在 Navy 中有一个 miniSDL 库,它可以提供一些兼容 SDL 的 API,这样这批 GUI 程序就可以很容易地移植到 Navy 中了:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/libs/libminiSDL$ tree
.
├── include
│   ├── sdl-audio.h
│   ├── sdl-event.h
│   ├── sdl-file.h
│   ├── sdl-general.h
│   ├── SDL.h
│   ├── sdl-timer.h
│   └── sdl-video.h
├── Makefile
└── src
├── audio.c
├── event.c
├── file.c
├── general.c
├── timer.c
└── video.c
2 directories, 14 files

我们可以通过 NDL 来支撑 miniSDL 的底层实现。

miniSDL 中的 API 和 SDL 同名,一定要通过 RTFM 了解 SDL API 的行为。

未实现的部分已经手动 assert(0) 了……

定点算术

NEMU 没有 FPU,在 AM 中执行浮点操作是 UB,Nanos-lite 认为浮点寄存器不属于上下文的一部分,Navy 中也不提供浮点数相关的运行时环境,我们在编译 Newlib 的时候定义了宏 NO_FLOATING_POINT

我们可以通过整数运算指令来实现实数的逻辑,而无需在硬件上引入 FPU,这样的一个算术体系称为定点算术

Navy 中提供了一个 fixedptc 的库,专门用于进行定点算术。fixedptc 库默认采用 32 位整数来表示实数,其具体格式为整数部分占 24 位,小数部分占 8 位。

库中定义了 fixedpt 的类型,用于表示定点数,可以看到它的本质是 int32_t 类型:

31 30 8 0
+----+---------------------------+----------+
|sign| integer | fraction |
+----+---------------------------+----------+

这样,对于一个实数 a,它的 fixedpt 类型表示 A = a * 2 ^ 8

RTFSC 可知转换的过程有细微的处理:

#define fixedpt_rconst(R) ((fixedpt)((R) * FIXEDPT_ONE + ((R) >= 0 ? 0.5 : -0.5)))
#define FIXEDPT_ONE ((fixedpt)((fixedpt)1 << FIXEDPT_FBITS))

另外 fixedpt_rconst 从表面上看带有非常明显的浮点操作,但从编译结果来看却没有任何浮点指令,可以使用 https://godbolt.org/ 观察。

这是因为 fixedpt编译器来负责了大部分的浮点处理。

参考了这篇博客……

对于负实数,我们用相应正数的相反数来表示。

我们需要在 fixedptc.h 中实现一些运算,较为简单:

/* Multiplies a fixedpt number with an integer, returns the result. */
static inline fixedpt fixedpt_muli(fixedpt A, int B) {
return A * B;
}
/* Divides a fixedpt number with an integer, returns the result. */
static inline fixedpt fixedpt_divi(fixedpt A, int B) {
return A / B;
}
/* Multiplies two fixedpt numbers, returns the result. */
static inline fixedpt fixedpt_mul(fixedpt A, fixedpt B) {
return A * B / FIXEDPT_ONE;
}
/* Divides two fixedpt numbers, returns the result. */
static inline fixedpt fixedpt_div(fixedpt A, fixedpt B) {
return (fixedpt)(((fixedptd) A * (fixedptd) FIXEDPT_ONE) / (fixedptd) B);
}
static inline fixedpt fixedpt_abs(fixedpt A) {
return A >= 0 ? A : -A;
}
static inline fixedpt fixedpt_floor(fixedpt A) {
if (fixedpt_fracpart(A) == 0) return A;
if (A > 0) return A & ~FIXEDPT_FMASK;
else return -((-A & ~FIXEDPT_FMASK) + FIXEDPT_ONE);
}
static inline fixedpt fixedpt_ceil(fixedpt A) {
if (fixedpt_fracpart(A) == 0) return A;
if (A > 0) return (A & ~FIXEDPT_FMASK) + FIXEDPT_ONE;
else return -(-A & ~FIXEDPT_FMASK);
}

写了一个测试程序,位于:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/libs/libfixedptc$ tree
.
├── fixedptc.c
├── include
│   └── fixedptc.h
├── Makefile
└── test
├── Makefile
└── test.c
2 directories, 5 files

下面是 test.c 的内容:

#include "../include/fixedptc.h"
#include <stdio.h>
#include <math.h>
void fixedpt_DUT() {
printf("*** demo:\n");
fixedpt a = fixedpt_rconst(1.2);
fixedpt b = fixedpt_fromint(10);
int c = 0;
if (b > fixedpt_rconst(7.9)) {
c = fixedpt_toint(fixedpt_div(fixedpt_mul(a + FIXEDPT_ONE, b), fixedpt_rconst(2.3)));
}
printf("%d\n", c);
...
}
void float_REF() {
printf("*** demo:\n");
float a = 1.2;
float b = 10;
int c = 0;
if (b > 7.9) {
c = (a + 1) * b / 2.3;
}
printf("%d\n", c);
...
}
int main() {
printf("--------- fixedpt_DUT ---------\n");
fixedpt_DUT();
printf("---------- float_REF ----------\n");
float_REF();
}

误差确实不小……

类似在 AM 中用 Linux native 的功能实现 AM 的 API,我们也可以用 Linux native 的功能来实现 Navy 应用程序所需的运行时环境,即操作系统相关的运行时环境:

这样我们就实现了操作系统相关的运行时环境与 Navy 应用程序的解耦。

我们在 Navy 中提供了一个特殊的 ISA 叫 native 来实现上述的解耦,它和其它 ISA 的不同之处在于:

可以在测试程序所在的目录下运行 make ISA=native run,来把测试程序编译到 Navy native 上并直接运行。

一个例外是 Navy 中的 dummy,由于它通过 _syscall_() 直接触发系统调用,这样的代码并不能直接在 Linux native 上直接运行,因为 Linux 不存在这个系统调用,或者编号不同。

以 Navy 应用程序的视角,抽象层大概如下:

|------|
| NEMU |
|------| <--- am_native for Navy, native for AM
| AM |
| OS |
|------| <--- native for Navy
| Navy |
|------|

也许……

主要目的是为了在 Linux native 的环境下单独测试 NDL 和 miniSDL 的代码……

为了 event-test 在 native 上愉快的运行,删了一个 close 的 assertion

bmp-test 反映出一些 VGA 的一些问题。之前 NDL_DrawRect 的实现中没有考虑到 lseekwrite 是以字节寻址的,而 pixels 的单位为 4 字节,所以正确的实现如下:

void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) {
int fd = open("/dev/fb", 0, 0);
for (int i = 0; i < h && y + i < canvas_h; ++i) {
lseek(fd, ((y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w)) * 4, SEEK_SET);
write(fd, pixels + i * w, 4 * (w < canvas_w - x ? w : canvas_w - x));
}
assert(close(fd) == 0);
}

另外还需要修改 fb_write,将 offsetlen 变为原来的 ¼:

size_t fb_write(const void *buf, size_t offset, size_t len) {
AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
int width = ev.width;
offset /= 4;
len /= 4;
int y = offset / width;
int x = offset - y * width;
io_write(AM_GPU_FBDRAW, x, y, (void *)buf, len, 1, true);
return len;
}

软件层和应用层都实现错了,负负得正……

有了 Navy native,就可以保证软件层和硬件层实现的正确性,控制变量……

另外,之前提到过 Nanos-lite 的 native,注意到 Nanos-lite 实际上是一个 AM 程序,也就是 AM native,对应 Navy 中的 ISA=am_native

我们可以在测试程序目录下键入 make ISA=am_native,得到 XXX-am_native 镜像,我们对比一下不同镜像的文件类型:

bmp-test-native: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=73fc7f8d1c78bc3994477bf41e3140fe225b0d40, for GNU/Linux 3.2.0, not stripped
bmp-test-am_native: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
bmp-test-riscv32: ELF 32-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, not stripped

同样可以在 Nanos-lite 目录下键入 make ARCH=native update,装载 XXX-am_native 形式的镜像,并键入 make ARCH=native run 运行。然而 loader 并未支持 64 位……

还有两处小问题:

navy-apps/libs/libos/src/syscall.c 中的:

int program_break = (int)(&_end);

编译期是无法得到 _end 的值的,所以应该改成:

int program_break;
int is_program_break_init = 0;
void *_sbrk(intptr_t increment) {
if (!is_program_break_init) { program_break = (int)(&_end); is_program_break_init = 1; }
...
}

nanos-lite/src/loader.c 中的 loader 函数:

memcpy((void *)(uintptr_t)virtAddr, buf_malloc, fileSiz);
memset((void *)(uintptr_t)(virtAddr + fileSiz), 0, memSiz - fileSiz);

在将 uint32_t 类型的地址转换为 void * 前,应加上 uintptr_t。因为整型和指针在一些机器上会有不同的大小(如在 64 位机器上),所以我们应该先转换成 uintptr_t

LD_PRELOAD

bmp-test 需要打开一个路径为 /share/pictures/projectn.bmp 的文件,但在 Linux native 中,这个路径对应的文件并不存在。

需要先了解一个叫 LD_PRELOAD 的环境变量:

The LD_PRELOAD trick is a useful technique to influence the linkage of shared libraries and the resolution of symbols (functions) at runtime.

这里有一个简单的例子,展示了如何 override 掉 C 的库函数。

观察 navy-apps/scripts/native.mk

LD = $(CXX)
### Run an application with $(ISA)=native
env:
$(MAKE) -C $(NAVY_HOME)/libs/libos ISA=native
run: app env
@LD_PRELOAD=$(NAVY_HOME)/libs/libos/build/native.so $(APP) $(mainargs)
gdb: app env
@LD_PRELOAD=$(NAVY_HOME)/libs/libos/build/native.so gdb --args $(APP) $(mainargs)
.PHONY: env run gdb

可以看到一个名为 native.so 的动态链接库会被优先链接。

我们可以观察程序的调试信息:

Redirecting file open: /share/pictures/projectn.bmp -> /home/vgalaxy/ics2021/navy-apps/fsimg/share/pictures/projectn.bmp

定位 navy-apps/libs/libos/src/native.cpp 可知:

static inline void get_fsimg_path(char *newpath, const char *path) {
sprintf(newpath, "%s%s", fsimg_path, path);
}
static const char* redirect_path(char *newpath, const char *path) {
get_fsimg_path(newpath, path);
if (0 == access(newpath, 0)) {
fprintf(stderr, "Redirecting file open: %s -> %s\n", path, newpath);
return newpath;
}
return path;
}

我们发现程序会执行 Init 类的构造函数:

struct Init {
Init() {
glibc_fopen = (FILE*(*)(const char*, const char*))dlsym(RTLD_NEXT, "fopen");
assert(glibc_fopen != NULL);
glibc_open = (int(*)(const char*, int, ...))dlsym(RTLD_NEXT, "open");
assert(glibc_open != NULL);
glibc_read = (ssize_t (*)(int fd, void *buf, size_t count))dlsym(RTLD_NEXT, "read");
assert(glibc_read != NULL);
glibc_write = (ssize_t (*)(int fd, const void *buf, size_t count))dlsym(RTLD_NEXT, "write");
assert(glibc_write != NULL);
glibc_execve = (int(*)(const char*, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
assert(glibc_execve != NULL);
dummy_fd = memfd_create("dummy", 0);
assert(dummy_fd != -1);
dispinfo_fd = dummy_fd;
char *navyhome = getenv("NAVY_HOME");
assert(navyhome);
sprintf(fsimg_path, "%s/fsimg", navyhome);
char newpath[512];
get_fsimg_path(newpath, "/bin");
setenv("PATH", newpath, 1); // overwrite the current PATH
SDL_Init(0);
if (!getenv("NWM_APP")) {
open_display();
open_event();
}
open_audio();
}
~Init() {
}
};
Init init;

dlsym 函数从一个动态链接库或者可执行文件中获取到符号地址……

fsimg_path 即为 navy-apps/fsimg

我们继续 RTFSC:

FILE *fopen(const char *path, const char *mode) {
char newpath[512];
return glibc_fopen(redirect_path(newpath, path), mode);
}
int open(const char *path, int flags, ...) {
if (strcmp(path, "/proc/dispinfo") == 0) {
return dispinfo_fd;
} else if (strcmp(path, "/dev/events") == 0) {
return evt_fd;
} else if (strcmp(path, "/dev/fb") == 0) {
return fb_memfd;
} else if (strcmp(path, "/dev/sb") == 0) {
return sb_fifo[1];
} else if (strcmp(path, "/dev/sbctl") == 0) {
return sbctl_fd;
} else {
char newpath[512];
return glibc_open(redirect_path(newpath, path), flags);
}
}

结合上面的 LD_PRELOAD,推测这里的 fopen 成功 override 掉了库函数,而原来的库函数通过重命名的方式变成了 glibc_fopen。这里的 fopen 将原来的文件路径如 /share/pictures/projectn.bmp 替换成了 /home/vgalaxy/ics2021/navy-apps/fsimg/share/pictures/projectn.bmp

调用轨迹参考:

fopen -> redirect_path -> get_fsimg_path
-> glibc_fopen

fopen 在底层会调用 open,而 open 也以同样的方式被 override 了。

另外一个有趣的地方在对特殊文件的模拟,以 /dev/fb 为例:

fb_memfd = memfd_create("fb", 0);
assert(fb_memfd != -1);
int ret = ftruncate(fb_memfd, FB_SIZE);
assert(ret == 0);
fb = (uint32_t *)mmap(NULL, FB_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fb_memfd, 0);
assert(fb != (void *)-1);
memset(fb, 0, FB_SIZE);
lseek(fb_memfd, 0, SEEK_SET);
memfd_create() creates an anonymous file and returns a file descriptor that refers to it. The file behaves like a regular file, and so can be modified, truncated, memory-mapped, and so on. However, unlike a regular file, it lives in RAM and has a volatile backing storage.

运行时环境兼容

实际上,navy-apps/libs/libos/src/native.cpp 使用了运行时环境兼容的技术。

即通过 Linux 的运行时环境实现 Navy 的运行时环境(API)

下面还有一些实际的例子:

但完整的 Linux 和 Windows 运行时环境太复杂了,因此一些对运行时环境依赖程度比较复杂的程序至今也很难在 Wine 或 WSL 上完美运行,以至于 WSL2 抛弃了”运行时环境兼容”的技术路线,转而采用虚拟机的方式来完美运行 Linux 系统。

NSlider (NJU Slider)

PDF to BMP

https://imagemagick.org/

不要 Install from Source,会变得不幸……

直接 sudo apt-get install imagemagick,然后 sudo vi /etc/ImageMagick-6/policy.xml,修改:

<policy domain="coder" rights="none" pattern="PDF" />

<policy domain="coder" rights="read|write" pattern="PDF" />

即可……

event

调用轨迹参考:

SDL_WaitEvent -> SDL_PollEvent -> NDL_PollEvent -> open / read / close `/dev/events`

redirect to evt_fd:

  • evt_fd = dup(dummy_fd)
  • dummy_fd = memfd_create("dummy", 0);

SDL 的相关实现如下:

int SDL_WaitEvent(SDL_Event *event) {
SDL_PollEvent(event);
return 1;
}
int SDL_PollEvent(SDL_Event *ev) {
unsigned buf_size = 32;
char *buf = (char *)malloc(buf_size * sizeof(char));
if (NDL_PollEvent(buf, buf_size) == 1) {
if (strncmp(buf, "kd", 2) == 0) {
ev->key.type = SDL_KEYDOWN;
} else {
ev->key.type = SDL_KEYUP;
}
for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) {
if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0) {
ev->key.keysym.sym = i;
break;
}
}
free(buf);
return 1;
} else {
ev->key.type = SDL_USEREVENT; // avoid too many `Redirecting file open ...`
ev->key.keysym.sym = 0;
}
free(buf);
return 0;
}

https://wiki.libsdl.org/SDL_KeyboardEvent

https://wiki.libsdl.org/SDL_PollEvent

https://wiki.libsdl.org/SDL_WaitEvent

另外,需要在 NDL_PollEvent 的最前面对 buf 清空,这是因为 malloc 出的 buf 可能不是全空……

int NDL_PollEvent(char *buf, int len) {
memset(buf, 0, len);
int fd = open("/dev/events", 0, 0);
int ret = read(fd, buf, len);
close(fd); // for event-test on native, no assertion here
return ret == 0 ? 0 : 1;
}

调试了半天……

native 中没有这个问题,只有在 nanos-lite 中才有这个问题……

这里让 NDL_PollEvent 处理一劳永逸,也可以让调用者 SDL_PollEvent 处理……

render

调用轨迹参考:

render -> SDL_LoadBMP
SDL_UpdateRect -> NDL_DrawRect -> open / read / close `/dev/fb`

SDL_UpdateRect 的实现如下:

void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) {
if (x == 0 && y == 0 && w == 0 && h == 0) {
NDL_DrawRect((uint32_t *)s->pixels, x, y, 400, 300); // assume the size of screen
return;
}
NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h);
}

https://wiki.libsdl.org/SDL_Surface

https://www.libsdl.org/release/SDL-1.2.15/docs/html/sdlupdaterect.html

一个很怪的地方,若在 NDL_DrawRectclose 了,native 中无法翻页:

void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) {
int fd = open("/dev/fb", 0, 0);
for (int i = 0; i < h && y + i < canvas_h; ++i) {
lseek(fd, ((y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w)) * 4, SEEK_SET);
write(fd, pixels + i * w, 4 * (w < canvas_w - x ? w : canvas_w - x));
}
// close(fd);
// 1. for NSlider on native, no assertion here
// 2. if close, the slide cannot turn pages on native
}

在 miniSDL 中实现两个绘图相关的 API:

需要注意的是,这两个 API 不需要在内部调用 NDL_DrawRect,只需要修改 dst->pixels 即可,初步实现如下:

void SDL_FillRect(SDL_Surface *dst, SDL_Rect *dstrect, uint32_t color) {
uint32_t * base = (uint32_t *)dst->pixels;
if (dstrect == NULL) {
for (int i = 0; i < dst->w * dst->h; ++i) base[i] = color;
return;
}
int rect_x = dstrect->x;
int rect_y = dstrect->y;
int rect_w = dstrect->w < (dst->w - dstrect->x) ? dstrect->w : (dst->w - dstrect->x);
int rect_h = dstrect->h < (dst->h - dstrect->y) ? dstrect->h : (dst->h - dstrect->y);
for (int i = 0; i < rect_h; ++i) {
for (int j = 0; j < rect_w; ++j) {
base[(rect_y + i) * dst->w + rect_x + j] = color;
}
}
return;
}
void SDL_BlitSurface(SDL_Surface *src, SDL_Rect *srcrect, SDL_Surface *dst, SDL_Rect *dstrect) {
assert(dst && src);
assert(dst->format->BitsPerPixel == src->format->BitsPerPixel);
uint32_t * data = (uint32_t *)src->pixels;
uint32_t * base = (uint32_t *)dst->pixels;
int src_w = src->w;
int src_h = src->h;
int dst_w = dst->w;
int dst_h = dst->h;
int dstrect_x = dstrect->x;
int dstrect_y = dstrect->y;
if (srcrect == NULL) {
assert(src_w <= (dst_w - dstrect_x));
assert(src_h <= (dst_h - dstrect_y));
// int width = src_w < (dst_w - dstrect_x) ? src_w : (dst_w - dstrect_x);
// int height = src_h < (dst_h - dstrect_y) ? src_h : (dst_h - dstrect_y);
for (int i = 0; i < src_h; ++i) {
for (int j = 0; j < src_w; ++j) {
base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[i * src_w + j];
}
}
return;
} else {
assert(0);
}
}

https://wiki.libsdl.org/SDL_BlitSurface

https://wiki.libsdl.org/SDL_FillRect

https://wiki.libsdl.org/SDL_Rect

NTerm (NJU Terminal)

NTerm 是一个模拟终端,它实现了终端的基本功能,包括字符的键入和回退,以及命令的获取等。

终端一般会和 Shell 配合使用,从终端获取到的命令将会传递给 Shell 进行处理,Shell 又会把信息输出到终端。

NTerm 自带一个非常简单的內建 Shell,见 builtin-sh.cpp,它默认忽略所有的命令。

API

需要实现 miniSDL 的两个 API:

manual 读的不仔细……

为此我们需要修改 SDL_WaitEventSDL_PollEvent

int SDL_PollEvent(SDL_Event *ev) {
unsigned buf_size = 32;
char *buf = (char *)malloc(buf_size * sizeof(char));
if (NDL_PollEvent(buf, buf_size) == 1) {
if (strncmp(buf, "kd", 2) == 0) {
ev->key.type = SDL_KEYDOWN;
} else {
ev->key.type = SDL_KEYUP;
}
int flag = 0;
for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) {
if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0
&& strlen(keyname[i]) == strlen(buf) - 4) {
flag = 1;
ev->key.keysym.sym = i;
break;
}
}
assert(flag == 1);
free(buf);
return 1;
} else {
return 0;
}
}
int SDL_WaitEvent(SDL_Event *ev) {
unsigned buf_size = 32;
char *buf = (char *)malloc(buf_size * sizeof(char));
while (NDL_PollEvent(buf, buf_size) == 0); // wait ...
if (strncmp(buf, "kd", 2) == 0)
ev->key.type = SDL_KEYDOWN;
else
ev->key.type = SDL_KEYUP;
int flag = 0;
for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) {
if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0
&& strlen(keyname[i]) == strlen(buf) - 4) {
flag = 1;
ev->key.keysym.sym = i;
break;
}
}
assert(flag == 1);
free(buf);
return 1;
}

另外需要修改对键盘按键名的识别,原来的实现中前缀也会被错误的识别,举个栗子,由于 TABT 之前,T 会被识别成 TAB ……

SDL_UpdateRect 函数也需要修改一下:

void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) {
if (x == 0 && y == 0 && w == 0 && h == 0) {
NDL_DrawRect((uint32_t *)s->pixels, x, y, s->w, s->h);
return;
}
NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h);
}

analysis

下面我们来分析一下这个终端的实现。首先是项目结构:

├── include
│   └── nterm.h
├── Makefile
└── src
├── builtin-sh.cpp
├── extern-sh.cpp
├── main.cpp
└── term.cpp

nterm.h 定义了 Terminal 类,term.cpp 给出了其实现:

目前需要关注的有如下几点:

wh 定义了终端的大小注意这里的单位是一个字符在屏幕上的大小,目前为 7×13

font -> w: 7; h: 13
win -> w: 336; h: 208

做除法,可得终端的大小为 48×16

buf -> 字符
color -> 颜色
dirty -> 是否被修改
struct Cursor {
int x, y;
} cursor, saved;

指示当前输入的地方。

main.cpp,主要看一下 main 函数:

int main(int argc, char *argv[]) {
SDL_Init(0);
font = new BDF_Font(font_fname);
// setup display
int win_w = font->w * W;
int win_h = font->h * H;
screen = SDL_SetVideoMode(win_w, win_h, 32, SDL_HWSURFACE);
term = new Terminal(W, H);
if (argc < 2) { builtin_sh_run(); }
else { extern_app_run(argv[1]); }
// should not reach here
assert(0);
}

这里的 SDL_SetVideoMode 打开了一个画布(并非全屏):

SDL_Surface* SDL_SetVideoMode(int width, int height, int bpp, uint32_t flags) {
if (flags & SDL_HWSURFACE) NDL_OpenCanvas(&width, &height);
return SDL_CreateRGBSurface(flags, width, height, bpp,
DEFAULT_RMASK, DEFAULT_GMASK, DEFAULT_BMASK, DEFAULT_AMASK);
}

构造出 Terminal 类的一个实例后调用 builtin_sh_run

builtin_sh_run 定义在 builtin-sh.cpp 中:

static void sh_printf(const char *format, ...) {
static char buf[256] = {};
va_list ap;
va_start(ap, format);
int len = vsnprintf(buf, 256, format, ap);
va_end(ap);
term->write(buf, len);
}
static void sh_banner() {
sh_printf("Built-in Shell in NTerm (NJU Terminal)\n\n");
}
static void sh_prompt() {
sh_printf("sh> ");
}
void builtin_sh_run() {
sh_banner();
sh_prompt();
while (1) {
SDL_Event ev;
if (SDL_PollEvent(&ev)) {
if (ev.type == SDL_KEYUP || ev.type == SDL_KEYDOWN) {
const char *res = term->keypress(handle_key(&ev));
if (res) {
sh_handle_cmd(res);
sh_prompt();
}
}
}
refresh_terminal();
}
}

我们需要理清两处地方:

考虑如下的调用轨迹:

sh_banner -> sh_printf -> write

write 成员函数以字符串和写入长度为参数:

void Terminal::write(const char *str, size_t count) {
for (size_t i = 0; i != count && str[i]; ) {
char ch = str[i];
if (ch == '\033') {
i += write_escape(&str[i], count - i);
} else {
switch (ch) {
case '\x07':
break;
case '\n':
cursor.x = 0;
cursor.y ++;
if (cursor.y >= h) {
scroll_up();
cursor.y --;
}
break;
case '\t':
// TODO: implement it.
break;
case '\r':
cursor.x = 0;
break;
default:
putch(cursor.x, cursor.y, ch);
move_one();
}
i ++;
}
}
}

遍历该字符串,对其中每个字符进行处理。先考虑一些特殊字符:

\033 -> ???
\t -> TAB?
\n -> 换行
\r -> 回车,即光标回到本行首位置

然后是普通字符,调用 putch,并让光标相应移动:

void Terminal::putch(int x, int y, char ch) {
buf[x + y * w] = ch;
color[x + y * w] = (col_f << 4) | col_b;
dirty[x + y * w] = true;
}

refresh_terminal 方法中,会对 putch 过的地方进行屏幕的同步,大概思路是:

void refresh_terminal() {
int needsync = 0;
for (int i = 0; i < W; i ++)
for (int j = 0; j < H; j ++)
if (term->is_dirty(i, j)) {
draw_ch(i * font->w, j * font->h, term->getch(i, j), term->foreground(i, j), term->background(i, j));
needsync = 1;
}
term->clear();
static uint32_t last = 0;
static int flip = 0;
uint32_t now = SDL_GetTicks();
if (now - last > 500 || needsync) {
int x = term->cursor.x, y = term->cursor.y;
uint32_t color = (flip ? term->foreground(x, y) : term->background(x, y));
draw_ch(x * font->w, y * font->h, ' ', 0, color);
SDL_UpdateRect(screen, 0, 0, 0, 0);
if (now - last > 500) {
flip = !flip;
last = now;
}
}
}

只要有一个 dirty,或者时间间隔超过了 0.5s,就会调用 SDL_UpdateRect 刷新屏幕。

draw_ch 调用了 SDL_BlitSurface

static void draw_ch(int x, int y, char ch, uint32_t fg, uint32_t bg) {
SDL_Surface *s = BDF_CreateSurface(font, ch, fg, bg);
SDL_Rect dstrect = { .x = x, .y = y };
SDL_BlitSurface(s, NULL, screen, &dstrect);
SDL_FreeSurface(s);
}

SDL_PollEvent 获取到键盘时间后,通过 term->keypress(handle_key(&ev)) 得到键盘输入并显示在终端上。

handle_key 返回对应的字符,这里还考虑了大小写的问题:

char handle_key(SDL_Event *ev) {
static int shift = 0;
int key = ev->key.keysym.sym;
if (key == SDLK_LSHIFT || key == SDLK_RSHIFT) { shift ^= 1; return '\0'; }
if (ev->type == SDL_KEYDOWN) {
for (auto item: SHIFT) {
if (item.keycode == key) {
if (shift) return item.shift;
else return item.noshift;
}
}
}
return '\0';
}

SHIFT 结构数组自行 RTFSC

cook 模式下,keypress 会调用 write 方法在终端显示输入,并在键入换行符时将输入返回,交由 builtin_sh_run 继续处理:

const char *Terminal::keypress(char ch) {
if (ch == '\0') return nullptr;
if (mode == Mode::raw) {
input[0] = ch;
input[1] = '\0';
return input;
} else if (mode == Mode::cook) {
const char *ret = nullptr;
switch (ch) {
case '\033':
break;
case '\n':
strcpy(cooked, input);
strcat(cooked, "\n");
ret = cooked;
write("\n", 1);
inp_len = 0;
break;
case '\b':
if (inp_len > 0) {
inp_len --;
backspace();
}
break;
default:
if (inp_len + 1 < sizeof(input)) {
input[inp_len ++] = ch;
write(&ch, 1);
}
}
input[inp_len] = '\0';
return ret;
}
return nullptr;
}

返回的输入最后会有一个换行符,我们可以在 sh_handle_cmd 中处理最简单的 echo 命令:

static void sh_handle_cmd(const char *cmd) {
if (cmd == NULL) return;
if (strncmp(cmd, "echo", 4) == 0) {
if (strlen(cmd) == 5) sh_printf("\n");
else sh_printf("%s", cmd + 5);
} else {
sh_printf("command not found\n");
}
}

Flappy Bird

基于 SDL1.2,对于 Linux native 而言(不是 Navy native),需要安装如下应用:

sudo apt-get install libsdl1.2-dev
sudo apt-get install libsdl-image1.2-dev

https://wiki.libsdl.org/FAQLinux

然而在链接的时候会报错 undefined reference to ...

不知道出了什么问题……

所以我们就自己利用 NDL 实现 SDL 吧……

需要实现 SDL_image 中的 IMG_Load(),这个库是基于 stb 项目 中的图像解码库来实现的,用于把解码后的像素封装成 SDL 的 Surface 结构。

一种实现方式如下:

  1. 用 libc 中的文件操作打开文件,并获取文件大小 size
  2. 申请一段大小为 size 的内存区间 buf
  3. 将整个文件读取到 buf 中
  4. 将 buf 和 size 作为参数,调用 STBIMG_LoadFromMemory(),它会返回一个 SDL_Surface 结构的指针
  5. 关闭文件,释放申请的内存
  6. 返回 SDL_Surface 结构指针

我们照葫芦画瓢,可得:

SDL_Surface* IMG_Load(const char *filename) {
FILE * fp = fopen(filename, "r");
if (!fp) return NULL;
fseek(fp, 0L, SEEK_END);
long size = ftell(fp);
rewind(fp);
unsigned char * buf = (unsigned char *)malloc(size * sizeof(unsigned char));
assert(fread(buf, 1, size, fp) == size);
SDL_Surface * surface = STBIMG_LoadFromMemory(buf, size);
assert(surface != NULL);
fclose(fp);
free(buf);
return surface;
}

然后去掉 SDL 库中的一些 assert,并修改 SDL_BlitSurface 如下:

void SDL_BlitSurface(SDL_Surface *src, SDL_Rect *srcrect, SDL_Surface *dst, SDL_Rect *dstrect) {
assert(dst && src);
assert(dst->format->BitsPerPixel == src->format->BitsPerPixel);
uint32_t * data = (uint32_t *)src->pixels;
uint32_t * base = (uint32_t *)dst->pixels;
int src_w = src->w;
int src_h = src->h;
int dst_w = dst->w;
int dst_h = dst->h;
int dstrect_x = !dstrect ? 0 : dstrect->x;
int dstrect_y = !dstrect ? 0 : dstrect->y;
if (srcrect == NULL) {
int width = src_w < (dst_w - dstrect_x) ? src_w : (dst_w - dstrect_x);
int height = src_h < (dst_h - dstrect_y) ? src_h : (dst_h - dstrect_y);
for (int i = 0; i < height; ++i) {
for (int j = 0; j < width; ++j) {
base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[i * src_w + j];
}
}
return;
} else {
int srcrect_x = srcrect->x;
int srcrect_y = srcrect->y;
int width = srcrect->w < (dst_w - dstrect_x) ? srcrect->w : (dst_w - dstrect_x);
int height = srcrect->h < (dst_h - dstrect_y) ? srcrect->h : (dst_h - dstrect_y);
for (int i = 0; i < height; ++i) {
for (int j = 0; j < width; ++j) {
base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[(srcrect_y + i) * src_w + srcrect_x + j];
}
}
return;
}
}

再次阅读 API 手册:

The width and height in srcrect determine the size of the copied rectangle. Only the position is used in the dstrect (the width and height are ignored).
If srcrect is NULL, the entire surface is copied. If dstrect is NULL, then the destination position (upper left corner) is (0, 0).

为了在 nanos-lite 上运行,还需要做两件事:

虽然在 nanos-lite 上没有可玩性……

只能在 Navy native 上玩了……

analysis

下面我们来分析一下这个游戏的实现。

首先是项目结构:

├── include
│   ├── Audio.h
│   ├── BirdGame.h
│   ├── BirdMain.h
│   ├── Sprite.h
│   └── Video.h
├── Makefile
├── README.txt
├── res
│   ├── atlas.png
│   ├── atlas.txt
│   ├── sfx_die.wav
│   ├── sfx_hit.wav
│   ├── sfx_point.wav
│   ├── sfx_swooshing.wav
│   ├── sfx_wing.wav
│   └── splash.png
└── src
├── Audio.cpp
├── BirdGame.cpp
├── BirdMain.cpp
├── Sprite.cpp
└── Video.cpp

先看 BirdGame.cpp 里的 main 函数:

int main(int argc, char *argv[])
{
// initialize SDL
if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO) < 0)
{
fprintf(stderr, "SDL_Init() failed: %s\n", SDL_GetError());
return 255;
}
atexit(SDL_Quit);
// initialize video
if (!VideoInit())
{
fprintf(stderr, "VideoInit() failed: %s\n", SDL_GetError());
return 255;
}
atexit(VideoDestroy);
// initialize audio
if (SOUND_OpenAudio(44100, 2, 1024) < 0)
{
fprintf(stderr, "InitSound() failed: %s\n", SDL_GetError());
return 255;
}
return GameMain();
}

进行一些初始化工作,注意这里 atexit 的使用,注册一些析构函数,在程序终止时执行,用于释放资源。

然后进入 GameMain,位于 BirdGame.cpp

int GameMain()
{
srand((unsigned int)time(NULL));
gpSprite = new CSprite(gpRenderer, FILE_PATH("atlas.png"), FILE_PATH("atlas.txt"));
atexit([](void) { delete gpSprite; });
LoadWav();
atexit(FreeWav);
atexit(SOUND_CloseAudio);
ShowTitle();
...

1、构造函数

首先构造一个 CSprite 类的对象,三个参数依次为:

bg_day 288 512 0 0
bg_night 288 512 292 0
...

对应了:

typedef struct tagSpritePart
{
unsigned short usWidth;
unsigned short usHeight;
unsigned short X, Y;
int hasAlpha;
} SpritePart_t;

构造函数调用轨迹:

CSprite::CSprite(SDL_Surface *pRenderer, const char *szImageFileName, const char *szTxtFileName)
{
int ret = hcreate(512);
assert(ret);
Load(pRenderer, szImageFileName, szTxtFileName);
}

hcreate 构造了一个哈希表。

Load 中调用了 IMG_Load 读取素材文件,并调用 LoadTxt 读取素材文件的解释文件,置入哈希表中。

2、ShowTitle

显示欢迎界面,等待一秒


GameMain 第二部分:

g_GameState = GAMESTATE_INITIAL;
unsigned int uiNextFrameTime = SDL_GetTicks();
unsigned int uiCurrentTime = SDL_GetTicks();
while (1)
{
// 60fps
do
{
uiCurrentTime = SDL_GetTicks();
UpdateEvents();
SDL_Delay(1);
} while (uiCurrentTime < uiNextFrameTime);
if ((int)(uiCurrentTime - uiNextFrameTime) > 1000)
{
uiNextFrameTime = uiCurrentTime + 1000 / 60;
}
else
{
uiNextFrameTime += 1000 / 60;
}

3、设置并更新时间

uiNextFrameTime -> prev

uiCurrentTime -> now

4、调用 UpdateEvents

Linux native 下可以使用鼠标……

static void UpdateEvents()
{
SDL_Event evt;
while (SDL_PollEvent(&evt))
{
switch (evt.type)
{
#ifndef __NAVY__
case SDL_QUIT:
exit(0);
break;
case SDL_MOUSEBUTTONDOWN:
g_bMouseDown = true;
g_iMouseX = evt.button.x;
g_iMouseY = evt.button.y;
break;
case SDL_MOUSEBUTTONUP:
g_bMouseDown = false;
break;
#endif
case SDL_KEYDOWN:
g_bMouseDown = true;
break;
case SDL_KEYUP:
g_bMouseDown = false;
break;
}
}
}

只记录是按下按键还是释放按键。

5、更新时间

if now - prev > 1000:
prev = now + 1000 / 60
else:
prev += 1000 / 60

最高为 60FPS


GameMain 第三部分:

switch (g_GameState)
{
case GAMESTATE_INITIAL:
GameThink_Initial();
break;
case GAMESTATE_GAMESTART:
GameThink_GameStart();
break;
case GAMESTATE_GAME:
GameThink_Game();
break;
case GAMESTATE_GAMEOVER:
GameThink_GameOver();
break;
default:
fprintf(stderr, "invalid game state: %d\n", (int)g_GameState);
exit(255);
}
SDL_UpdateRect(gpRenderer, 0, 0, 0, 0);
}
return 255; // shouldn't really reach here
}

6、根据 g_GameState 进行逻辑处理

绘制初始画面,用户按下按键后设置 g_GameStateGAMESTATE_GAMESTART

绘制准备画面,用户按下按键后设置 g_GameStateGAMESTATE_GAME

绘制 pipe 和 bird,若 bird 碰到 pipe,设置 g_GameStateGAMESTATE_GAMEOVER。期间记录分数

游戏失败的画面依阶段绘制:

static enum { FLASH, DROP, SHOWTITLE, SHOWSCORE }

并在 SHOWSCORE 阶段且按下按键后设置 g_GameStateGAMESTATE_GAME

似乎游戏的结束比较困难……

7、更新画面

SDL_UpdateRect

移植和测试

我们在移植游戏的时候,会按顺序在四种环境中运行游戏:

和 Project-N 的组件没有任何关系,用于保证游戏本身确实可以正确运行。在更换库的版本或者修改游戏代码之后,都会先在 Linux native 上进行测试

用 Navy 中的库替代 Linux native 的库,测试游戏是否能在 Navy 库的支撑下正确运行

用 Nanos-lite, libos 和 Newlib 替代 Linux 的系统调用和 glibc,测试游戏是否能在 Nanos-lite 及其运行时环境的支撑下正确运行

用 NEMU 替代真机硬件,测试游戏是否能在 NEMU 的支撑下正确运行

我们之所以能这样做,都是得益于计算机是个抽象层这个结论:我们可以把某个抽象层之下的部分替换成一个可靠的实现,先独立测试一个抽象层的不可靠实现,然后再把其它抽象层的不可靠实现逐个替换进来并测试。不过这要求你编写的代码都是可移植的,否则将无法支持抽象层的替换。

PAL (仙剑奇侠传)

数据文件下载地址:https://box.nju.edu.cn/f/73c08ca0a5164a94aaba/

implementation & debug

native:

SDL_PollEventSDL_WaitEvent 不再 malloc 出字符数组,使用 static 的字符数组。

0x00007ffff7c3650f in unlink_chunk (p=p@entry=0x5555561d3cc0, av=0x7ffff7d83ba0 <main_arena>)
at malloc.c:1607
1607 malloc.c: No such file or directory.
(gdb) bt
#0 0x00007ffff7c3650f in unlink_chunk (p=p@entry=0x5555561d3cc0,
av=0x7ffff7d83ba0 <main_arena>) at malloc.c:1607
#1 0x00007ffff7c3864c in _int_malloc (av=av@entry=0x7ffff7d83ba0 <main_arena>,
bytes=bytes@entry=1696) at malloc.c:4266
#2 0x00007ffff7c3a2f1 in __GI___libc_malloc (bytes=1696) at malloc.c:3237
#3 0x0000555555575113 in YJ1_Decompress ()
#4 0x000000000000010a in ?? ()
#5 0x00005555561fb8e0 in ?? ()
#6 0x00007ffff6a2b800 in ?? ()
#7 0x00000000f7cca313 in ?? ()
#8 0x0000000000000000 in ?? ()

nanos-lite:

address (0x44a1f9cb) is out of bound at pc = 0x8303421c

报错的原因是因为不同格式的 pixels 所占的空间不同。需要对应修改 SDL_BlitSurfaceSDL_FillRectSDL_UpdateRect。调色板机制在 SDL_UpdateRect 中实现:

void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) {
assert(s);
assert(s->format);
if (s->format->palette == NULL) { // 32-bits
if (x == 0 && y == 0 && w == 0 && h == 0) {
NDL_DrawRect((uint32_t *)s->pixels, x, y, s->w, s->h);
return;
}
NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h);
} else { // 8-bits
assert(s->format->palette->colors);
int W, H;
if (x == 0 && y == 0 && w == 0 && h == 0) {
W = s->w;
H = s->h;
} else {
W = w;
H = h;
}
uint8_t * pixels_index = (uint8_t *)s->pixels;
uint32_t * pixels = (uint32_t *)malloc(W * H * sizeof(uint32_t *));
for (int i = 0; i < W * H; ++i) {
SDL_Color colors = s->format->palette->colors[pixels_index[i]];
uint32_t p = (colors.a << 24) | (colors.r << 16) | (colors.g << 8) | (colors.b << 0);
pixels[i] = p;
}
NDL_DrawRect(pixels, x, y, W, H);
free(pixels);
}
}

https://wiki.libsdl.org/SDL_GetKeyboardState

static unsigned char keystate[sizeof(keyname) / sizeof(keyname[0])];
uint8_t* SDL_GetKeyState(int *numkeys) {
SDL_Event ev;
if (SDL_PollEvent(&ev) == 1 && ev.key.type == SDL_KEYDOWN) {
keystate[ev.key.keysym.sym] = 1;
} else {
memset(keystate, 0, sizeof(keystate));
}
return keystate;
}

脚本引擎

navy-apps/apps/pal/repo/src/game/script.c 中有一个 PAL_InterpretInstruction() 的函数,尝试大致了解这个函数的作用和行为。然后大胆猜测一下,仙剑奇侠传的开发者是如何开发这款游戏的?

am-kernels

在 Navy 中有一个 libam 的库,用来实现 AM 的 API。

我们需要使用 Navy 的运行时环境实现这些 API,这样我们就可以在 Navy 上运行各种 AM 应用了。

主要思想:

举个栗子:

(Navy) __am_timer_uptime -> gettimeofday -> sys_gettimeofday -> io_read -> ioe_read -> (AM) _am_timer_uptime

AM 程序并不知道自己调用的 API 绕了一个大圈……

实现

下面研究一下 libam 库:

├── include
│   ├── amdev.h -> /home/vgalaxy/ics2021/abstract-machine/am/include/amdev.h
│   ├── am.h
│   ├── am-origin.h -> /home/vgalaxy/ics2021/abstract-machine/am/include/am.h
│   ├── klib.h -> /home/vgalaxy/ics2021/abstract-machine/klib/include/klib.h
│   ├── klib-macros.h -> /home/vgalaxy/ics2021/abstract-machine/klib/include/klib-macros.h
│   └── navy.h
├── Makefile
└── src
├── ioe.c
└── trm.cpp

先看一下 Makefile:

ifeq ($(wildcard include/am-origin.h),)
ifeq ($(wildcard $(AM_HOME)/am/include/am.h),)
$(error $$AM_HOME/am/include/amdev.h will be used. Please set $$AM_HOME to an AbstractMachine repo)
else
$(info Setup link to header files)
$(shell ln -sf -T $(AM_HOME)/am/include/am.h include/am-origin.h)
$(shell ln -sf -T $(AM_HOME)/am/include/amdev.h include/amdev.h)
$(shell ln -sf -T $(AM_HOME)/klib/include/klib.h include/klib.h)
$(shell ln -sf -T $(AM_HOME)/klib/include/klib-macros.h include/klib-macros.h)
endif
endif

第一行的意思是匹配是否有 include/am-origin.h 这个文件,如果没有的话会返回空,也就是 ifeq 语句条件成立。我们刚开始没有这个文件,所以会进入第二行。

第一行的意思是匹配是否有 $(AM_HOME)/am/include/am.h 这个文件,显然是有的,跳过报错,并建立一下软链接,原来的 am.h 被链接到了 am-origin.h

include 目录下的 am.h 包含了这些头文件:

#ifndef __AM_H__
#define __AM_H__
#define ARCH_H "navy.h"
#include "am-origin.h"
#include "amdev.h"
#include "klib.h"
#include "klib-macros.h"
#endif

这里的 ARCH_H 会在 am-origin.h 中被包含。

navy.h 是一些要用到的 newlib

#ifndef __NAVY_H__
#define __NAVY_H__
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#endif

然后我们在 libam 中实现 TRM:

#include <am.h>
Area heap;
#define nemu_trap(code) asm volatile("mv a0, %0; .word 0x0000006b" : :"r"(code))
void putch(char ch) {
putchar(ch);
}
void halt(int code) {
nemu_trap(code);
// should not reach here
while (1);
}

然后是 IOE:

static void __am_timer_config(AM_TIMER_CONFIG_T *cfg) {
cfg->present = true;
cfg->has_rtc = true;
}
void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
struct timeval tv;
assert(gettimeofday(&tv, NULL) == 0);
uptime->us = tv.tv_sec * 1000000 + tv.tv_usec;
}
void __am_timer_rtc(AM_TIMER_RTC_T *rtc) {
rtc->second = 0;
rtc->minute = 0;
rtc->hour = 0;
rtc->day = 0;
rtc->month = 0;
rtc->year = 1900;
}

仿 NDL_GetTicks

#define _KEYS(_) \
_(ESCAPE) _(F1) _(F2) _(F3) _(F4) _(F5) _(F6) _(F7) _(F8) _(F9) _(F10) _(F11) _(F12) \
_(GRAVE) _(1) _(2) _(3) _(4) _(5) _(6) _(7) _(8) _(9) _(0) _(MINUS) _(EQUALS) _(BACKSPACE) \
_(TAB) _(Q) _(W) _(E) _(R) _(T) _(Y) _(U) _(I) _(O) _(P) _(LEFTBRACKET) _(RIGHTBRACKET) _(BACKSLASH) \
_(CAPSLOCK) _(A) _(S) _(D) _(F) _(G) _(H) _(J) _(K) _(L) _(SEMICOLON) _(APOSTROPHE) _(RETURN) \
_(LSHIFT) _(Z) _(X) _(C) _(V) _(B) _(N) _(M) _(COMMA) _(PERIOD) _(SLASH) _(RSHIFT) \
_(LCTRL) _(APPLICATION) _(LALT) _(SPACE) _(RALT) _(RCTRL) \
_(UP) _(DOWN) _(LEFT) _(RIGHT) _(INSERT) _(DELETE) _(HOME) _(END) _(PAGEUP) _(PAGEDOWN)
#define keyname(k) #k,
static const char *keyname[] = {
"NONE",
_KEYS(keyname)
};
static void __am_input_config(AM_INPUT_CONFIG_T *cfg) {
cfg->present = true;
}
void __am_input_keybrd(AM_INPUT_KEYBRD_T *kbd) {
unsigned buf_size = 32;
char *buf = (char *)malloc(buf_size * sizeof(char));
memset(buf, 0, buf_size);
int fd = open("/dev/events", 0, 0);
int ret = read(fd, buf, buf_size);
close(fd);
if (ret > 0) {
if (strncmp(buf, "kd", 2) == 0) {
kbd->keydown = 1;
} else {
kbd->keydown = 0;
}
int flag = 0;
for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) {
if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0
&& strlen(keyname[i]) == strlen(buf) - 4) {
flag = 1;
kbd->keycode = i;
break;
}
}
assert(flag == 1);
} else {
kbd->keycode = 0;
}
free(buf);
}

仿 SDL_PollEvent

static int gpu_sync = false;
void __am_gpu_config(AM_GPU_CONFIG_T *cfg) {
uint16_t w = 400;
uint16_t h = 300;
*cfg = (AM_GPU_CONFIG_T) {
.present = true, .has_accel = false,
.width = w,
.height = h,
.vmemsz = w * h * sizeof(uint32_t)
};
}
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) {
int x = ctl->x;
int y = ctl->y;
int w = ctl->w;
int h = ctl->h;
uint32_t * base = (uint32_t *) ctl->pixels;
int fd = open("/dev/fb", 0, 0);
for (int i = 0; i < h && y + i < 300; ++i) {
lseek(fd, ((y + i) * 400 + x) * 4, SEEK_SET);
write(fd, base + i * w, 4 * (w < 400 - x ? w : 400 - x));
}
if (ctl->sync) {
gpu_sync = true;
} else {
gpu_sync = false;
}
}
void __am_gpu_status(AM_GPU_STATUS_T *status) {
status->ready = gpu_sync;
}

仿 NDL_DrawRect

最后与 AM 一样加上回调函数表:

typedef void (*handler_t)(void *buf);
static void *lut[128] = {
[AM_TIMER_CONFIG] = __am_timer_config,
[AM_TIMER_RTC ] = __am_timer_rtc,
[AM_TIMER_UPTIME] = __am_timer_uptime,
[AM_INPUT_CONFIG] = __am_input_config,
[AM_INPUT_KEYBRD] = __am_input_keybrd,
[AM_GPU_CONFIG ] = __am_gpu_config,
[AM_GPU_FBDRAW ] = __am_gpu_fbdraw,
[AM_GPU_STATUS ] = __am_gpu_status,
};
static void fail(void *buf) { panic("access nonexist register"); }
bool ioe_init() {
for (int i = 0; i < LENGTH(lut); i++)
if (!lut[i]) lut[i] = fail;
return true;
}
void ioe_read (int reg, void *buf) { ((handler_t)lut[reg])(buf); }
void ioe_write(int reg, void *buf) { ((handler_t)lut[reg])(buf); }

运行

实现之后,navy-apps/apps/am-kernels/Makefile 会把 libam 加入链接的列表。我们在 navy-apps/apps/am-kernels 目录下键入:

make ISA=riscv32 ALL=typing-game install
make ISA=riscv32 ALL=coremark install
make ISA=riscv32 ALL=dhrystone install

就可以在 navy-apps/fsimg/bin 下得到镜像文件。

am-kernels 对应的 build 文件夹下也会有变化

然后我们在 nanos-lite 中更新:

make ARCH=riscv32-nemu update

就可以运行了……

运行中一些奇怪的现象:

关掉 trace,跑分似乎并不差,甚至比无 OS 还要好……

尝试把 microbench 编译到 Navy 并运行:

FCEUX

vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/apps/fceux$ make ISA=riscv32 install

似乎无法指定操作系统所加载程序的 main 函数的参数……

于是 main 的参数 romnameNULL,导致解引用 NULL

调用轨迹参考:

main -> LoadGame -> FCEUI_LoadGame -> FCEUI_LoadGameVirtual -> strcpy

理论上甚至可以在 Navy 上运行 Nanos-lite,开始套娃……

oslab0

学长学姐在他们的 OS 课上编写了一些基于 AM 的小游戏……

navy-apps/apps/oslab0/ 目录下执行 make ISA=riscv32 ALL=XXX install

试了试推箱子、贪吃蛇、雷电……

有些似乎运行不了:

反汇编代码:

vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ riscv64-linux-gnu-objdump -d nanos-lite-riscv32-nemu.elf | less
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/fsimg/bin$ riscv64-linux-gnu-objdump -d 161220016 | less

发布代码所有的命名都是随机字符串,就像这样:

static void AlM0UT25fL(uint32_t vZkqLfCIMW) {
for (int cZ5d65ts5U = kZ6hP_qvSg.eZelonvCFU; cZ5d65ts5U < kZ6hP_qvSg.hck0ckSSZL; ++cZ5d65ts5U)
gZ5nzpxC8r(vZkqLfCIMW, kZ6hP_qvSg.XD7kFstGd3[cZ5d65ts5U] - kZ6hP_qvSg.UQRn26Bmz5[cZ5d65ts5U],
RsBhhiFFvE.J_Sl6Dag5p / 2, kZ6hP_qvSg.UQRn26Bmz5[cZ5d65ts5U] * 2, 3);
}

实现思路:对源文件进行词法和语法分析,找到标识符,并随机化……

基础设施

自由开关 DiffTest 模式

略,因为一直没用上 DiffTest

快照

程序是个 S = <R, M> 的状态机

寄存器可以表示为 R = {GPR, PC, SR},其中 SR 为系统寄存器

还是感觉没啥用,因为 NEMU 一直都是 batch mode

SYS_execve

我们需要一个新的系统调用 SYS_execve

操作系统层:

case SYS_execve:
#ifdef CONFIG_STRACE
Log("sys_execve(%s, %d, %d)", (const char *)c->GPR2, c->GPR3, c->GPR4);
#endif
sys_execve((const char *)c->GPR2);
while(1);

若成功执行其他程序,该系统调用处理函数是不返回的:

int sys_execve(const char *fname) {
naive_uload(NULL, fname);
return -1;
}

应用层:

int _execve(const char *fname, char * const argv[], char *const envp[]) {
return _syscall_(SYS_execve, (intptr_t)fname, 0, 0);
}

目前暂时忽略 argvenvp 这两个参数。

有了这个系统调用之后,开机菜单就可以发挥全部功力了:

if (i != -1 && i <= i_max) {
i += page * 10;
auto *item = &items[i];
const char *exec_argv[3];
exec_argv[0] = item->bin;
exec_argv[1] = item->arg1;
exec_argv[2] = NULL;
clear_display();
SDL_UpdateRect(screen, 0, 0, 0, 0);
execve(exec_argv[0], (char**)exec_argv, (char**)envp);
fprintf(stderr, "\033[31m[ERROR]\033[0m Exec %s failed.\n\n", exec_argv[0]);
} else {
fprintf(stderr, "Choose a number between %d and %d\n\n", 0, i_max);
}

我们还可以修改 SYS_exit 的实现,让它调用 SYS_execve 来再次运行 /bin/menu,而不是直接调用 halt() 来结束整个系统的运行。

然而使用 exit 结束的 Navy / AM 程序并不多……

我们可以选择一个跑分的程序……

随着应用程序数量的增加,使用开机菜单来运行程序就不是那么方便了。我们可以通过 NTerm 来运行这些程序,只要键入程序的路径,例如 /bin/pal

在 NTerm 的內建 Shell 中实现命令解析:

#define fname_buf_size 128
static char fname[128];
static void sh_handle_cmd(const char *cmd) {
if (cmd == NULL) return;
if (strncmp(cmd, "echo", 4) == 0) {
if (strlen(cmd) == 5) sh_printf("\n");
else sh_printf("%s", cmd + 5);
} else {
if (strlen(cmd) > fname_buf_size) {
sh_printf("command too long\n");
return;
}
memset(fname, 0, fname_buf_size);
strncpy(fname, cmd, strlen(cmd) - 1);
// execve(fname, NULL, NULL);
execvp(fname, NULL);
}
}

注意去掉 cmd 最后的换行符。

并在主循环前定义 PATH 这个环境变量,这样就不用键入命令的完整路径啦:

assert(setenv("PATH", "/bin", 0) == 0);

RTFM 了解 setenv()execvp() 的行为……

似乎 /bin 或者 /bin/ 都可以……

一些问题:

当然会报错……

于是开机动画(音乐)成了可能……

Report

How Hello 2

构建了文件系统后,我们需要重新回答 Hello World 程序是如何出现在内存中的。

在 Navy-apps 的 Makefile 中添加相应的应用程序或测试程序后,在 nanos-lite 中执行 make ARCH=riscv32-nemu update

update:
$(MAKE) -s -C $(NAVY_HOME) ISA=$(ISA) ramdisk
@ln -sf $(NAVY_HOME)/build/ramdisk.img $(RAMDISK_FILE)
@ln -sf $(NAVY_HOME)/build/ramdisk.h src/files.h
@ln -sf $(NAVY_HOME)/libs/libos/src/syscall.h src/syscall.h

输出结果为:

# Building nanos-lite-update [riscv32-nemu]
make -s -C /home/vgalaxy/ics2021/navy-apps ISA=riscv32 ramdisk
# Building -ramdisk [riscv32]
# Building hello-install [riscv32]
# Building compiler-rt-archive [riscv32]
+ AR -> build/compiler-rt-riscv32.a
# Building libc-archive [riscv32]
+ AR -> build/libc-riscv32.a
# Building libos-archive [riscv32]
+ AR -> build/libos-riscv32.a
+ LD -> build/hello-riscv32
+ INSTALL -> hello

其中的 Building 信息显示了名称和构建规则:

### Print build info message
$(info # Building $(NAME)-$(MAKECMDGOALS) [$(ISA)])

我们使用 make -nB 进一步观察可知:

1、基本流程:ramdisk -> fsimg -> install / …

# Building nanos-lite-update [riscv32-nemu]
make -s -C /home/vgalaxy/ics2021/navy-apps ISA=riscv32 ramdisk
# Building -ramdisk [riscv32]
for t in tests/hello; do make -s -C /home/vgalaxy/ics2021/navy-apps/$t install; done

对应 Navy-apps 的 Makefile

fsimg: $(addprefix apps/, $(APPS)) $(addprefix tests/, $(TESTS))
-for t in $^; do $(MAKE) -s -C $(NAVY_HOME)/$$t install; done
RAMDISK = build/ramdisk.img
RAMDISK_H = build/ramdisk.h
$(RAMDISK): fsimg
$(eval FSIMG_FILES := $(shell find -L ./fsimg -type f))
@mkdir -p $(@D)
@cat $(FSIMG_FILES) > $@
@truncate -s \%512 $@
@echo "// file path, file size, offset in disk" > $(RAMDISK_H)
@wc -c $(FSIMG_FILES) | grep -v 'total$$' | sed -e 's+ ./fsimg+ +' | awk -v sum=0 '{print "\x7b\x22" $$2 "\x22\x2c " $$1 "\x2c " sum "\x7d\x2c";sum += $$1}' >> $(RAMDISK_H)
ramdisk: $(RAMDISK)

2、细化:fsimg 规则中对每个条目执行了 install 规则:

install: app
@echo + INSTALL "->" $(NAME)
@mkdir -p $(NAVY_HOME)/fsimg/bin
@cp $(APP) $(NAVY_HOME)/fsimg/bin/$(NAME)

install 规则又依赖于 app 规则:

app: $(APP)
$(APP): $(OBJS) libs
@echo + LD "->" $(shell realpath $@ --relative-to .)
@$(LD) $(LDFLAGS) -o $@ $(WL)--start-group $(LINKAGE) $(WL)--end-group

其中 OBJS 代表可重定位目标文件:

OBJS = $(addprefix $(DST_DIR)/, $(addsuffix .o, $(basename $(SRCS))))

此时会编译得到 hello.chello.o 文件。

libs 规则如下:

libs:
@for t in $(LIBS); do $(MAKE) -s -C $(NAVY_HOME)/libs/$$t archive; done

其中 $(LIBS)compiler-rt libc libos。逐一执行 archive 规则:

archive: $(ARCHIVE)
...
$(ARCHIVE): $(OBJS) libs
@echo + AR "->" $(shell realpath $@ --relative-to .)
@ar rcsT $@ $(LINKAGE)

得到各自的 .a 文件。

之后回到 $(APP) 进行链接,再回到 install 规则将得到的可执行文件复制到 fsing/bin 中。

3、install 之后的规则

得到镜像文件之后,我们指定 naive_uload 初始加载的程序,这里为 /bin/ntermloader 方法会通过 fs_open 方法以二进制方式打开文件(程序),并将代码和数据加载到内存中。最后操作系统将控制转移到 nterm 程序。

nterm 键入 hello 后,nterm 程序会试图将输入解析成需要运行的程序名,在设置了环境变量后,通过 SYS_execve 系统调用加载 hello 程序。

之后的过程就与 How Hello 1 中一致了。

PAL Crane

这一动画是通过 navy-apps/apps/pal/repo/src/main.c 中的 PAL_SplashScreen() 函数播放的。阅读这一函数,可以得知仙鹤的像素信息存放在数据文件 mgo.mkf 中。

为了简化分析,我们主要讨论,我们的计算机系统是如何从 mgo.mkf 文件中读出仙鹤的像素信息,并且更新到屏幕上。

先说明一下 PAL 需要用到的库:

回顾一下,libndl 是对 IOE 的封装,libminiSDL 的底层实现为 libndl,而 libfixedptc 就是我们之前实现的定点运算库

参考 PAL_SplashScreen() 函数的注释,可以大概分析出应用程序的行为: